├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .gradle └── 4.10.2 │ ├── fileChanges │ └── last-build.bin │ ├── fileHashes │ └── fileHashes.lock │ └── gc.properties ├── .metadata ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── android ├── README.md ├── app │ ├── build.gradle │ └── src │ │ ├── debug │ │ ├── AndroidManifest.xml │ │ └── res │ │ │ └── values │ │ │ └── strings.xml │ │ ├── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── circles_app │ │ │ │ ├── ImageResize.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── PermissionHandler.kt │ │ │ │ ├── ThumbnailUtils.kt │ │ │ │ └── UploadPlatform.kt │ │ └── res │ │ │ ├── drawable-hdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-mdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-nodpi │ │ │ └── splash.png │ │ │ ├── drawable-xhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable-xxxhdpi │ │ │ └── ic_launcher_foreground.png │ │ │ ├── drawable │ │ │ └── launch_background.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ └── ic_launcher.xml │ │ │ ├── mipmap-hdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-mdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── mipmap-xxxhdpi │ │ │ ├── ic_launcher.png │ │ │ └── launcher_icon.png │ │ │ ├── values-de │ │ │ └── strings.xml │ │ │ └── values │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ └── profile │ │ └── AndroidManifest.xml ├── build.gradle ├── gradle.properties ├── gradle │ └── wrapper │ │ └── gradle-wrapper.properties ├── settings.gradle └── store │ ├── feature.png │ ├── hi-res-icon.png │ ├── screenshot1.png │ └── screenshot2.png ├── assets ├── graphics │ ├── avatar_no_picture.png │ ├── calendar │ │ └── calendar_today.png │ ├── channel │ │ ├── create_new_channel.png │ │ ├── details_date.png │ │ ├── details_location.png │ │ ├── details_members.png │ │ ├── details_padlock.png │ │ ├── event_joined.png │ │ ├── event_open.png │ │ ├── header_calendar_icon.png │ │ ├── padlock.png │ │ ├── rsvp │ │ │ ├── rsvp_maybe.png │ │ │ ├── rsvp_maybe_large.png │ │ │ ├── rsvp_no_large.png │ │ │ ├── rsvp_yes.png │ │ │ └── rsvp_yes_large.png │ │ ├── topic_joined.png │ │ └── topic_open.png │ ├── drawer │ │ ├── account.png │ │ ├── create_topic.png │ │ ├── direct_message.png │ │ ├── events.png │ │ └── settings.png │ ├── icon_notification.png │ ├── icon_smile.png │ ├── input │ │ ├── checkbox_active.png │ │ ├── checkbox_inactive.png │ │ ├── icon_add_content.png │ │ ├── icon_camera.png │ │ └── icon_pictures.png │ ├── menu_icon.png │ ├── menu_more_icon.png │ ├── update_indicator_darkgreen.png │ ├── updates_indicator.png │ ├── updates_indicator_white.png │ ├── upload │ │ ├── indicator_0_try_again.png │ │ └── selected.png │ ├── visual_twist.png │ └── visual_twist_white_petrol.png ├── icon │ └── icon.png └── placeholder │ ├── 2.0x │ └── user_image_placeholder.png │ ├── 3.0x │ └── user_image_placeholder.png │ └── user_image_placeholder.png ├── firebase ├── .gitignore ├── README.md ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── functions │ ├── .gitignore │ ├── admin.js │ ├── calendar-update.js │ ├── channel-edit.js │ ├── channel-flagChannelUnread.js │ ├── channel-new-user.js │ ├── channel-new.js │ ├── channel-remove-user.js │ ├── channel-remove.js │ ├── channel-updatedAt.js │ ├── channel-util.js │ ├── constants.js │ ├── cron │ │ └── event-notify-about-upcoming-event.js │ ├── group-update.js │ ├── group-util.js │ ├── index.js │ ├── localize-util.js │ ├── message-get.js │ ├── message-new.js │ ├── message-update.js │ ├── package-lock.json │ ├── package.json │ ├── push-send.js │ ├── reaction-push.js │ ├── rsvp-update.js │ ├── user-groupUpdate.js │ ├── user-new.js │ └── user-util.js ├── package-lock.json └── scripts │ └── group-create.js ├── firestore-1.png ├── firestore-2.png ├── firestore-3.png ├── firestore-4.png ├── fonts ├── Edmondsans-Bold.otf ├── Edmondsans-Medium.otf ├── Edmondsans-Regular.otf ├── Poppins-ExtraBold.ttf └── Poppins-Regular.ttf ├── ios ├── Flutter │ ├── AppFrameworkInfo.plist │ ├── Debug.xcconfig │ └── Release.xcconfig ├── Podfile ├── Podfile.lock ├── Runner.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ └── contents.xcworkspacedata │ └── xcshareddata │ │ └── xcschemes │ │ └── Runner.xcscheme ├── Runner.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Runner │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-60.png │ │ ├── Icon-60@2x.png │ │ ├── Icon-60@3x.png │ │ ├── Icon-76.png │ │ ├── Icon-76@2x.png │ │ ├── Icon-Small-1.png │ │ ├── Icon-Small.png │ │ ├── Icon-Small20.png │ │ ├── Icon-Small@2x-1.png │ │ ├── Icon-Small@2x.png │ │ ├── Icon-Small@3x.png │ │ ├── Icon-Spotlight-40.png │ │ ├── Icon-Spotlight-40@2x-1.png │ │ ├── Icon-Spotlight-40@2x.png │ │ ├── Icon-Spotlight-40@3x.png │ │ ├── Icon-Spotlight-41.png │ │ ├── Icon-Spotlight-42.png │ │ ├── Icon-iPadPro@2x.png │ │ └── timy-icon.png │ ├── Contents.json │ └── LaunchImage.imageset │ │ ├── Contents.json │ │ └── splash screen.pdf │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── ImageProcessingService.swift │ ├── Info-dev.plist │ ├── Info.plist │ ├── PermissionService.swift │ ├── Runner-Bridging-Header.h │ ├── Runner-dev.entitlements │ ├── Runner.entitlements │ ├── UploadService.swift │ ├── de.lproj │ ├── InfoPlist.strings │ ├── LaunchScreen.strings │ └── Main.strings │ └── en.lproj │ └── InfoPlist.strings ├── lib ├── circles_app.dart ├── circles_localization.dart ├── cupertinoLocalizationDelegate.dart ├── data │ ├── calendar_repository.dart │ ├── channel_repository.dart │ ├── file_repository.dart │ ├── firestore_paths.dart │ ├── group_repository.dart │ ├── message_repository.dart │ └── user_repository.dart ├── domain │ └── redux │ │ ├── app_actions.dart │ │ ├── app_middleware.dart │ │ ├── app_reducer.dart │ │ ├── app_selector.dart │ │ ├── app_state.dart │ │ ├── app_state.g.dart │ │ ├── attachment │ │ ├── attachment_actions.dart │ │ ├── attachment_middleware.dart │ │ └── image_processor.dart │ │ ├── authentication │ │ ├── auth_actions.dart │ │ ├── auth_middleware.dart │ │ └── auth_reducer.dart │ │ ├── calendar │ │ ├── calendar_actions.dart │ │ ├── calendar_middleware.dart │ │ └── calendar_reducer.dart │ │ ├── channel │ │ ├── channel_actions.dart │ │ ├── channel_middleware.dart │ │ └── channel_reducer.dart │ │ ├── message │ │ ├── message_actions.dart │ │ ├── message_middleware.dart │ │ └── message_reducer.dart │ │ ├── push │ │ ├── push_actions.dart │ │ ├── push_middleware.dart │ │ └── push_reducer.dart │ │ ├── stream_subscriptions.dart │ │ ├── ui │ │ ├── ui_actions.dart │ │ ├── ui_reducer.dart │ │ ├── ui_state.dart │ │ ├── ui_state.g.dart │ │ └── ui_state_selector.dart │ │ └── user │ │ ├── user_actions.dart │ │ ├── user_middleware.dart │ │ └── user_reducer.dart ├── main.dart ├── model │ ├── calendar_entry.dart │ ├── calendar_entry.g.dart │ ├── channel.dart │ ├── channel.g.dart │ ├── channel_state.dart │ ├── channel_state.g.dart │ ├── group.dart │ ├── group.g.dart │ ├── in_app_notification.dart │ ├── in_app_notification.g.dart │ ├── message.dart │ ├── message.g.dart │ ├── reaction.dart │ ├── reaction.g.dart │ ├── user.dart │ └── user.g.dart ├── native_channels │ ├── android_permission_channel.dart │ ├── android_thumbnail_channel.dart │ ├── ios_permission_channel.dart │ └── upload_platform.dart ├── presentation │ ├── calendar │ │ ├── calendar_item.dart │ │ ├── calendar_screen.dart │ │ ├── calendar_screen_viewmodel.dart │ │ └── calendar_screen_viewmodel.g.dart │ ├── channel │ │ ├── channel_screen.dart │ │ ├── channel_screen_viewmodel.dart │ │ ├── channel_screen_viewmodel.g.dart │ │ ├── create │ │ │ └── create_channel.dart │ │ ├── details │ │ │ ├── topic_details.dart │ │ │ ├── topic_details_viewmodel.dart │ │ │ └── topic_details_viewmodel.g.dart │ │ ├── event │ │ │ ├── create_event.dart │ │ │ ├── event_details.dart │ │ │ ├── event_details_viewmodel.dart │ │ │ ├── event_details_viewmodel.g.dart │ │ │ ├── rsvp_dialog.dart │ │ │ └── rsvp_header.dart │ │ ├── input │ │ │ ├── attach_button.dart │ │ │ ├── chat_input.dart │ │ │ ├── chat_input_viewmodel.dart │ │ │ ├── chat_input_viewmodel.g.dart │ │ │ └── send_button.dart │ │ ├── invite │ │ │ ├── invite_to_channel_screen.dart │ │ │ ├── invite_to_channel_viewmodel.dart │ │ │ └── invite_to_channel_viewmodel.g.dart │ │ ├── join_channel.dart │ │ ├── message │ │ │ ├── media │ │ │ │ └── MediaMessage.dart │ │ │ ├── message_body.dart │ │ │ ├── message_item.dart │ │ │ ├── message_timestamp.dart │ │ │ └── system_message_item.dart │ │ ├── messages_list │ │ │ ├── messages_list.dart │ │ │ ├── messages_list_viewmodel.dart │ │ │ └── messages_list_viewmodel.g.dart │ │ ├── messages_scroll_controller.dart │ │ ├── messages_section.dart │ │ └── reaction │ │ │ ├── emoji_picker.dart │ │ │ ├── reaction.dart │ │ │ ├── reaction_button.dart │ │ │ ├── reaction_detail_data.dart │ │ │ ├── reaction_detail_data.g.dart │ │ │ ├── reaction_details.dart │ │ │ └── reaction_section.dart │ ├── common │ │ ├── color_label_text_form_field.dart │ │ ├── common_app_bar.dart │ │ ├── date_form_field.dart │ │ ├── error_label_text_form_field.dart │ │ ├── modal_item.dart │ │ ├── platform_alerts.dart │ │ ├── round_button.dart │ │ └── time_form_field.dart │ ├── home │ │ ├── channel_list │ │ │ ├── channel_list.dart │ │ │ ├── channel_list_item.dart │ │ │ ├── channel_list_viewmodel.dart │ │ │ ├── channel_list_viewmodel.g.dart │ │ │ ├── event_status_icon_widget.dart │ │ │ └── group_status_icon_widget.dart │ │ ├── circles_drawer.dart │ │ ├── group_list │ │ │ ├── group_list.dart │ │ │ ├── group_list_viewmodel.dart │ │ │ └── group_list_viewmodel.g.dart │ │ ├── home_app_bar.dart │ │ ├── home_app_bar_viewmodel.dart │ │ ├── home_app_bar_viewmodel.g.dart │ │ ├── homescreen.dart │ │ ├── in_app_notification │ │ │ ├── in_app_notification_viewmodel.dart │ │ │ ├── in_app_notification_viewmodel.g.dart │ │ │ └── in_app_notification_widget.dart │ │ ├── main_screen.dart │ │ ├── main_screen_viewmodel.dart │ │ ├── main_screen_viewmodel.g.dart │ │ └── slide_out_screen.dart │ ├── image │ │ ├── file_picker_item.dart │ │ ├── file_picker_screen.dart │ │ ├── image_pinch_screen.dart │ │ ├── image_screen.dart │ │ └── image_with_loader.dart │ ├── login │ │ ├── auth_button.dart │ │ ├── auth_button_container.dart │ │ └── loginscreen.dart │ ├── settings │ │ ├── privacy_settings_button.dart │ │ └── settings_screen.dart │ └── user │ │ ├── profile_avatar.dart │ │ ├── rsvp_icon.dart │ │ ├── selected_item.dart │ │ ├── user_avatar.dart │ │ ├── user_item.dart │ │ ├── user_screen.dart │ │ ├── user_screen_viewmodel.dart │ │ └── user_screen_viewmodel.g.dart ├── routes.dart ├── theme.dart └── util │ ├── HexColor.dart │ ├── cache.dart │ ├── date_formatting.dart │ ├── logger.dart │ └── permissions.dart ├── pubspec.lock ├── pubspec.yaml ├── res └── values │ └── strings_en.arb ├── test ├── data │ ├── FirestoreMocks.dart │ ├── channel_repository_test.dart │ ├── circle_repository_test.dart │ ├── data_mocks.dart │ ├── message_repository_test.dart │ └── user_repository_test.dart ├── domain │ ├── auth_middleware_test.dart │ ├── auth_reducer_test.dart │ ├── channel │ │ └── channel_middleware_test.dart │ ├── message │ │ ├── message_middleware_test.dart │ │ └── message_reducer_test.dart │ ├── middleware_test.dart │ ├── push │ │ ├── push_middleware_test.dart │ │ └── push_reducer_test.dart │ ├── reducer_test.dart │ ├── redux_mocks.dart │ └── user │ │ ├── user_middleware_test.dart │ │ └── user_reducer_test.dart ├── model │ └── message_test.dart ├── presentation │ ├── channel │ │ ├── create │ │ │ └── create_channel_test.dart │ │ ├── event │ │ │ └── create_event_test.dart │ │ └── invite │ │ │ └── invite_to_channel_viewmodel_test.dart │ ├── home │ │ └── channel_list_viewmodel_test.dart │ └── reaction │ │ └── reaction_details_test.dart ├── synchronous_error.dart └── widget_test.dart └── timy.png /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before reporting a bug, be sure that you followed the README.md instructions.** 11 | 12 | If you still had a bug after following the instructions, please go ahead filling this template. 13 | 14 | All incomplete bug reports will be marked as 'invalid' and closed. 15 | 16 | **Describe the bug** 17 | 18 | A clear and concise description of what the bug is. 19 | 20 | **To Reproduce** 21 | 22 | Steps to reproduce the behavior: 23 | 1. Go to '...' 24 | 2. Click on '....' 25 | 3. Scroll down to '....' 26 | 4. See error 27 | 28 | **Expected behavior** 29 | 30 | A clear and concise description of what you expected to happen. 31 | 32 | **Screenshots** 33 | 34 | If applicable, add screenshots to help explain your problem. 35 | 36 | **Device Information:** 37 | 38 | - Device: [e.g. iPhone6] 39 | - OS: [e.g. iOS8.1] 40 | 41 | **Flutter Information** 42 | 43 | - `flutter doctor` output 44 | 45 | **Logs** 46 | 47 | ``` 48 | Paste relevant output logs inside this quotes 49 | ``` 50 | 51 | **Additional context** 52 | 53 | Add any other context about the problem here. 54 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | This project is no longer under development, but we are still open to ideas and maybe someone wants to build them. 11 | That's why we would like to hear from you if you would like to have a new feature. 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | 15 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 16 | 17 | **Describe the solution you'd like** 18 | 19 | A clear and concise description of what you want to happen. 20 | 21 | **Describe alternatives you've considered** 22 | 23 | A clear and concise description of any alternative solutions or features you've considered. 24 | 25 | **Additional context** 26 | 27 | Add any other context or screenshots about the feature request here. 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # Visual Studio Code related 19 | .vscode/ 20 | 21 | # Flutter/Dart/Pub related 22 | **/doc/api/ 23 | .dart_tool/ 24 | .flutter-plugins 25 | .packages 26 | .pub-cache/ 27 | .pub/ 28 | /build/ 29 | 30 | # Android related 31 | **/android/**/gradle-wrapper.jar 32 | **/android/.gradle 33 | **/android/captures/ 34 | **/android/gradlew 35 | **/android/gradlew.bat 36 | **/android/local.properties 37 | **/android/**/GeneratedPluginRegistrant.java 38 | **/android/**/google-services.json 39 | 40 | # iOS/XCode related 41 | **/ios/**/*.mode1v3 42 | **/ios/**/*.mode2v3 43 | **/ios/**/*.moved-aside 44 | **/ios/**/*.pbxuser 45 | **/ios/**/*.perspectivev3 46 | **/ios/**/*sync/ 47 | **/ios/**/.sconsign.dblite 48 | **/ios/**/.tags* 49 | **/ios/**/.vagrant/ 50 | **/ios/**/DerivedData/ 51 | **/ios/**/Icon? 52 | **/ios/**/Pods/ 53 | **/ios/**/.symlinks/ 54 | **/ios/**/profile 55 | **/ios/**/xcuserdata 56 | **/ios/.generated/ 57 | **/ios/Flutter/App.framework 58 | **/ios/Flutter/Flutter.framework 59 | **/ios/Flutter/Generated.xcconfig 60 | **/ios/Flutter/app.flx 61 | **/ios/Flutter/app.zip 62 | **/ios/Flutter/flutter_assets/ 63 | **/ios/ServiceDefinitions.json 64 | **/ios/Runner/GeneratedPluginRegistrant.* 65 | **/ios/Flutter/flutter_export_environment.sh 66 | 67 | # Exceptions to above rules. 68 | !**/ios/**/default.mode1v3 69 | !**/ios/**/default.mode2v3 70 | !**/ios/**/default.pbxuser 71 | !**/ios/**/default.perspectivev3 72 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 73 | janoodle-prod-firebase-adminsdk.json 74 | -------------------------------------------------------------------------------- /.gradle/4.10.2/fileChanges/last-build.bin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gradle/4.10.2/fileHashes/fileHashes.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/.gradle/4.10.2/fileHashes/fileHashes.lock -------------------------------------------------------------------------------- /.gradle/4.10.2/gc.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/.gradle/4.10.2/gc.properties -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 7a4c33425ddd78c54aba07d86f3f9a4a0051769b 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Timy Messenger 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | The following is a guideline for contributing to Timy Messenger. 6 | These are mostly guidelines, not rules. Use your best judgment, 7 | and feel free to propose changes to this document in a pull request. 8 | 9 | ## Expectation vs Reality 10 | 11 | Timy Messenger is an open source app created originally 12 | by Janoodle Unlimited GmbH as part of an Minimum Viable Product test. 13 | It is currently Open Source and available for you to use, change and play with. 14 | 15 | However, **this project is no longer under development**, 16 | which means that there's no plan to add new features to it. 17 | The original developers are still maintainers of the project and will keep an eye on it, but don't expect quick responses. 18 | 19 | ## How Can I Contribute? 20 | 21 | ### Reporting Bugs 22 | 23 | If you notice something not working, feel free to report a bug using the "Report Bug" template. 24 | 25 | **All bugs reported that don't follow the template will be marked as 'invalid' and closed.** 26 | 27 | ### Adding Documentation 28 | 29 | Any sort of documentation is welcome. 30 | 31 | For example: 32 | 33 | - Setup instructions. 34 | - Documenting methods with useful comments. 35 | - Links to external resources like blog posts or videos talking about the app. 36 | 37 | ### Providing Translations 38 | 39 | We are happy to add more translations to the app. 40 | 41 | ### Fixing Reported Bugs 42 | 43 | We are happy to add any fixes to the reported bugs in "Issues". 44 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # Specify analysis options. 2 | # 3 | # For a list of lints, see: http://dart-lang.github.io/linter/lints/ 4 | # See the configuration guide for more 5 | # https://github.com/dart-lang/sdk/tree/master/pkg/analyzer#configuring-the-analyzer 6 | # 7 | # There are four similar analysis options files in the flutter repos: 8 | # - analysis_options.yaml 9 | # - packages/flutter/lib/analysis_options_user.yaml (this file) 10 | # - https://github.com/flutter/plugins/blob/master/analysis_options.yaml 11 | # - https://github.com/flutter/engine/blob/master/analysis_options.yaml 12 | # 13 | analyzer: 14 | errors: 15 | # treat missing required parameters as a warning (not a hint) 16 | missing_required_param: warning 17 | exclude: 18 | - '**.g.dart' 19 | 20 | linter: 21 | rules: 22 | - avoid_empty_else 23 | - avoid_init_to_null 24 | - avoid_return_types_on_setters 25 | - await_only_futures 26 | - camel_case_types 27 | - cancel_subscriptions 28 | - close_sinks 29 | - control_flow_in_finally 30 | - empty_constructor_bodies 31 | - empty_statements 32 | - hash_and_equals 33 | - implementation_imports 34 | - library_names 35 | - non_constant_identifier_names 36 | - package_api_docs 37 | - package_names 38 | - package_prefixed_library_names 39 | - prefer_const_constructors_in_immutables 40 | - prefer_double_quotes 41 | - prefer_final_fields 42 | - prefer_final_in_for_each 43 | - prefer_final_locals 44 | - prefer_is_not_empty 45 | - slash_for_doc_comments 46 | - test_types_in_equals 47 | - throw_in_finally 48 | - type_init_formals 49 | - unawaited_futures 50 | - unnecessary_brace_in_string_interps 51 | - unnecessary_const 52 | - unnecessary_getters_setters 53 | - unnecessary_new 54 | - unnecessary_statements 55 | - unnecessary_this 56 | - unrelated_type_equality_checks 57 | - valid_regexps 58 | -------------------------------------------------------------------------------- /android/README.md: -------------------------------------------------------------------------------- 1 | # Timy Android 2 | 3 | ## Setting up Firebase 4 | 5 | Create a project on the Firebase console [here](https://console.firebase.google.com/) 6 | 7 | 1. To add Firebase to your app, click on the android icon or click the gear icon to go to project 8 | settings to find the android icon. 9 | 10 | 2. Register your application by filing up the form with the package name (applicationId) 11 | and the app nickname if you like. 12 | > Find Your package name which is generally the applicationId in your app-level build.gradle file 13 | 14 | 3. Download the `google-service.json` file that is generated for you. Find it and move it inside 15 | the folder `android/app/` of the project. The firebase sdk is already added to the project. 16 | 17 | 4. On the fourth step of registration, run the app to verify the configuration via the Firebase 18 | console. 19 | 20 | ## Distribution 21 | 22 | To build this application for distribution, 23 | provide a file `key.jks` containing the signing keys, 24 | and the `key.properties` with the following content: 25 | 26 | ``` 27 | storePassword=..... 28 | keyPassword=..... 29 | keyAlias=key 30 | storeFile=../key.jks 31 | ``` 32 | 33 | Where you set the `storePassword` and the `keyPassword`. You can also change the alias. 34 | 35 | You will need to uncomment the section `signingConfigs` in the `app/build.gradle`. 36 | 37 | And change the `signingConfig signingConfigs.debug` to `signingConfig signingConfigs.release`. 38 | 39 | You can also provide this files from your CI instead of including this in the project. 40 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/debug/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Timy DEV 4 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 9 | 13 | 20 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/circles_app/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.circles_app 2 | 3 | import android.os.Bundle 4 | 5 | import io.flutter.app.FlutterActivity 6 | import io.flutter.plugin.common.MethodChannel 7 | import io.flutter.plugins.GeneratedPluginRegistrant 8 | 9 | private const val UPLOAD_PLATFORM = "de.janoodle.timy/upload_platform" 10 | private const val PERMISSION = "de.janoodle.timy/permission-android" 11 | private const val THUMBNAILS = "de.janoodle.timy/thumbnails-android" 12 | 13 | class MainActivity : FlutterActivity() { 14 | 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | super.onCreate(savedInstanceState) 17 | GeneratedPluginRegistrant.registerWith(this) 18 | 19 | MethodChannel(flutterView, UPLOAD_PLATFORM).setMethodCallHandler { call, result -> 20 | when (call.method) { 21 | "uploadFiles" -> uploadFilesTask(call, applicationContext) 22 | } 23 | } 24 | 25 | MethodChannel(flutterView, PERMISSION).setMethodCallHandler { call, result -> 26 | when (call.method) { 27 | "requestPermission" -> PermissionHandler.request(call, this, result) 28 | } 29 | } 30 | 31 | MethodChannel(flutterView, THUMBNAILS).setMethodCallHandler { call, result -> 32 | when (call.method) { 33 | "getThumbnailBitmap" -> loadBitmap(call, this, result) 34 | } 35 | } 36 | 37 | } 38 | 39 | override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { 40 | super.onRequestPermissionsResult(requestCode, permissions, grantResults) 41 | PermissionHandler.onRequestPermissionsResult(requestCode, permissions, grantResults) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/circles_app/PermissionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.example.circles_app 2 | 3 | import android.app.Activity 4 | import android.content.pm.PackageManager 5 | import androidx.core.app.ActivityCompat 6 | import io.flutter.plugin.common.MethodCall 7 | import io.flutter.plugin.common.MethodChannel 8 | 9 | object PermissionHandler { 10 | private var result: MethodChannel.Result? = null 11 | private const val REQUEST_CODE = 1 12 | 13 | fun request(call: MethodCall, activity: Activity, result: MethodChannel.Result) { 14 | this.result = result 15 | if (ActivityCompat.checkSelfPermission(activity, call.permission()) != PackageManager.PERMISSION_GRANTED) { 16 | ActivityCompat.requestPermissions(activity, arrayOf(call.permission()), REQUEST_CODE) 17 | } else { 18 | result.success(mapOf(call.permission() to PackageManager.PERMISSION_GRANTED)) 19 | } 20 | } 21 | 22 | fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { 23 | if (requestCode == REQUEST_CODE) { 24 | result?.success(mapOf(permissions[0] to grantResults[0])) 25 | } 26 | } 27 | } 28 | 29 | private fun MethodCall.permission(): String { 30 | return argument("permissionType")!! 31 | } 32 | -------------------------------------------------------------------------------- /android/app/src/main/kotlin/com/example/circles_app/ThumbnailUtils.kt: -------------------------------------------------------------------------------- 1 | package com.example.circles_app 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.graphics.Bitmap 6 | import android.os.Handler 7 | import android.os.Looper 8 | import android.provider.MediaStore 9 | import io.flutter.plugin.common.MethodCall 10 | import io.flutter.plugin.common.MethodChannel 11 | import java.io.ByteArrayOutputStream 12 | import java.util.concurrent.ExecutorService 13 | import java.util.concurrent.Executors 14 | 15 | private val executor: ExecutorService = Executors.newFixedThreadPool(1) 16 | 17 | fun loadBitmap(call: MethodCall, activity: Activity, result: MethodChannel.Result) { 18 | val fileId = call.argument("fileId") 19 | val type = call.argument("type") 20 | if (fileId == null || type == null) { 21 | result.error("INVALID_ARGUMENTS", "fileId or type must not be null", null) 22 | return 23 | } 24 | executor.execute { 25 | val byteArray = getThumbnailBitmap(context = activity, fileId = fileId.toLong(), type = type) 26 | Handler(Looper.getMainLooper()).post { 27 | result.success(byteArray) 28 | } 29 | } 30 | } 31 | 32 | fun getThumbnailBitmap(context: Context, fileId: Long, type: Int): ByteArray? { 33 | val bitmap: Bitmap = when (type) { 34 | 0 -> { 35 | MediaStore.Images.Thumbnails.getThumbnail( 36 | context.contentResolver, fileId, 37 | MediaStore.Images.Thumbnails.MINI_KIND, null) 38 | } 39 | 1 -> { 40 | MediaStore.Video.Thumbnails.getThumbnail( 41 | context.contentResolver, fileId, 42 | MediaStore.Video.Thumbnails.MINI_KIND, null) 43 | 44 | } 45 | else -> null 46 | } ?: return null 47 | val stream = ByteArrayOutputStream() 48 | bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) 49 | bitmap.recycle() 50 | return stream.toByteArray() 51 | } 52 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-nodpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/drawable-nodpi/splash.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-hdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-mdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-de/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dateien werden hochgeladen 4 | Datei %d von %d 5 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #000000 4 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Timy 4 | Uploading files 5 | File %d of %d 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | -------------------------------------------------------------------------------- /android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | ext.kotlin_version = '1.3.0' 3 | repositories { 4 | google() 5 | jcenter() 6 | maven { 7 | url 'https://maven.fabric.io/public' 8 | } 9 | } 10 | 11 | dependencies { 12 | classpath 'com.android.tools.build:gradle:3.2.1' 13 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 14 | classpath 'com.google.gms:google-services:4.2.0' 15 | classpath 'io.fabric.tools:gradle:1.26.1' 16 | } 17 | } 18 | 19 | allprojects { 20 | repositories { 21 | google() 22 | jcenter() 23 | } 24 | } 25 | 26 | rootProject.buildDir = '../build' 27 | subprojects { 28 | project.buildDir = "${rootProject.buildDir}/${project.name}" 29 | } 30 | subprojects { 31 | project.evaluationDependsOn(':app') 32 | } 33 | subprojects { 34 | project.configurations.all { 35 | resolutionStrategy { 36 | force "androidx.core:core:1.0.2" 37 | force "androidx.localbroadcastmanager:localbroadcastmanager:1.0.0-rc01" 38 | } 39 | } 40 | } 41 | 42 | task clean(type: Delete) { 43 | delete rootProject.buildDir 44 | } 45 | 46 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | android.enableR8=true 5 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip 7 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | 3 | def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() 4 | 5 | def plugins = new Properties() 6 | def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') 7 | if (pluginsFile.exists()) { 8 | pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } 9 | } 10 | 11 | plugins.each { name, path -> 12 | def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() 13 | include ":$name" 14 | project(":$name").projectDir = pluginDirectory 15 | } 16 | -------------------------------------------------------------------------------- /android/store/feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/store/feature.png -------------------------------------------------------------------------------- /android/store/hi-res-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/store/hi-res-icon.png -------------------------------------------------------------------------------- /android/store/screenshot1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/store/screenshot1.png -------------------------------------------------------------------------------- /android/store/screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/android/store/screenshot2.png -------------------------------------------------------------------------------- /assets/graphics/avatar_no_picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/avatar_no_picture.png -------------------------------------------------------------------------------- /assets/graphics/calendar/calendar_today.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/calendar/calendar_today.png -------------------------------------------------------------------------------- /assets/graphics/channel/create_new_channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/create_new_channel.png -------------------------------------------------------------------------------- /assets/graphics/channel/details_date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/details_date.png -------------------------------------------------------------------------------- /assets/graphics/channel/details_location.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/details_location.png -------------------------------------------------------------------------------- /assets/graphics/channel/details_members.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/details_members.png -------------------------------------------------------------------------------- /assets/graphics/channel/details_padlock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/details_padlock.png -------------------------------------------------------------------------------- /assets/graphics/channel/event_joined.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/event_joined.png -------------------------------------------------------------------------------- /assets/graphics/channel/event_open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/event_open.png -------------------------------------------------------------------------------- /assets/graphics/channel/header_calendar_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/header_calendar_icon.png -------------------------------------------------------------------------------- /assets/graphics/channel/padlock.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/padlock.png -------------------------------------------------------------------------------- /assets/graphics/channel/rsvp/rsvp_maybe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/rsvp/rsvp_maybe.png -------------------------------------------------------------------------------- /assets/graphics/channel/rsvp/rsvp_maybe_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/rsvp/rsvp_maybe_large.png -------------------------------------------------------------------------------- /assets/graphics/channel/rsvp/rsvp_no_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/rsvp/rsvp_no_large.png -------------------------------------------------------------------------------- /assets/graphics/channel/rsvp/rsvp_yes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/rsvp/rsvp_yes.png -------------------------------------------------------------------------------- /assets/graphics/channel/rsvp/rsvp_yes_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/rsvp/rsvp_yes_large.png -------------------------------------------------------------------------------- /assets/graphics/channel/topic_joined.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/topic_joined.png -------------------------------------------------------------------------------- /assets/graphics/channel/topic_open.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/channel/topic_open.png -------------------------------------------------------------------------------- /assets/graphics/drawer/account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/drawer/account.png -------------------------------------------------------------------------------- /assets/graphics/drawer/create_topic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/drawer/create_topic.png -------------------------------------------------------------------------------- /assets/graphics/drawer/direct_message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/drawer/direct_message.png -------------------------------------------------------------------------------- /assets/graphics/drawer/events.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/drawer/events.png -------------------------------------------------------------------------------- /assets/graphics/drawer/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/drawer/settings.png -------------------------------------------------------------------------------- /assets/graphics/icon_notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/icon_notification.png -------------------------------------------------------------------------------- /assets/graphics/icon_smile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/icon_smile.png -------------------------------------------------------------------------------- /assets/graphics/input/checkbox_active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/input/checkbox_active.png -------------------------------------------------------------------------------- /assets/graphics/input/checkbox_inactive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/input/checkbox_inactive.png -------------------------------------------------------------------------------- /assets/graphics/input/icon_add_content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/input/icon_add_content.png -------------------------------------------------------------------------------- /assets/graphics/input/icon_camera.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/input/icon_camera.png -------------------------------------------------------------------------------- /assets/graphics/input/icon_pictures.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/input/icon_pictures.png -------------------------------------------------------------------------------- /assets/graphics/menu_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/menu_icon.png -------------------------------------------------------------------------------- /assets/graphics/menu_more_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/menu_more_icon.png -------------------------------------------------------------------------------- /assets/graphics/update_indicator_darkgreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/update_indicator_darkgreen.png -------------------------------------------------------------------------------- /assets/graphics/updates_indicator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/updates_indicator.png -------------------------------------------------------------------------------- /assets/graphics/updates_indicator_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/updates_indicator_white.png -------------------------------------------------------------------------------- /assets/graphics/upload/indicator_0_try_again.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/upload/indicator_0_try_again.png -------------------------------------------------------------------------------- /assets/graphics/upload/selected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/upload/selected.png -------------------------------------------------------------------------------- /assets/graphics/visual_twist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/visual_twist.png -------------------------------------------------------------------------------- /assets/graphics/visual_twist_white_petrol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/graphics/visual_twist_white_petrol.png -------------------------------------------------------------------------------- /assets/icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/icon/icon.png -------------------------------------------------------------------------------- /assets/placeholder/2.0x/user_image_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/placeholder/2.0x/user_image_placeholder.png -------------------------------------------------------------------------------- /assets/placeholder/3.0x/user_image_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/placeholder/3.0x/user_image_placeholder.png -------------------------------------------------------------------------------- /assets/placeholder/user_image_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/assets/placeholder/user_image_placeholder.png -------------------------------------------------------------------------------- /firebase/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | 9 | # Firebase cache 10 | .firebase/ 11 | 12 | # Firebase config 13 | 14 | # Uncomment this if you'd like others to create their own Firebase project. 15 | # For a team working on the same Firebase project(s), it is recommended to leave 16 | # it commented so all members can deploy to the same project(s) in .firebaserc. 17 | # .firebaserc 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (http://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | .env 66 | -------------------------------------------------------------------------------- /firebase/README.md: -------------------------------------------------------------------------------- 1 | # Timy app on Firebase 2 | 3 | This folder contains all Firebase related code that does not belong to the client app. 4 | 5 | ## Structure 6 | 7 | - `functions` contains all the code for cloud functions 8 | - `scripts` contains local scripts for database admin and migration help 9 | - `firestore.rules` defines the access rules for Firestore by app users 10 | -------------------------------------------------------------------------------- /firebase/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firebase/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "calendar", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "users", 9 | "arrayConfig": "CONTAINS" 10 | }, 11 | { 12 | "fieldPath": "event_date", 13 | "order": "ASCENDING" 14 | } 15 | ] 16 | }, 17 | { 18 | "collectionGroup": "channels", 19 | "queryScope": "COLLECTION", 20 | "fields": [ 21 | { 22 | "fieldPath": "has_start_time", 23 | "order": "ASCENDING" 24 | }, 25 | { 26 | "fieldPath": "type", 27 | "order": "ASCENDING" 28 | }, 29 | { 30 | "fieldPath": "start_date", 31 | "order": "ASCENDING" 32 | } 33 | ] 34 | }, 35 | { 36 | "collectionGroup": "channels", 37 | "queryScope": "COLLECTION", 38 | "fields": [ 39 | { 40 | "fieldPath": "type", 41 | "order": "ASCENDING" 42 | }, 43 | { 44 | "fieldPath": "start_date", 45 | "order": "ASCENDING" 46 | } 47 | ] 48 | } 49 | ], 50 | "fieldOverrides": [] 51 | } 52 | -------------------------------------------------------------------------------- /firebase/firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | function userIsMemberOfGroup(groupId) { 4 | return request.auth.uid in get(/databases/$(database)/documents/groups/$(groupId)).data.members; 5 | } 6 | 7 | match /calendar/{calendarId} { 8 | allow read, write: if request.auth.uid != null; 9 | } 10 | 11 | match /groups/{groupId} { 12 | allow read, write: if request.auth.uid in resource.data.members; 13 | 14 | // TODO: Channels should be filtered on client side by their visibility 15 | match /channels/{channelId} { 16 | allow read, write: if userIsMemberOfGroup(groupId); 17 | 18 | match /users/{userId} { 19 | allow read, create, update: if userIsMemberOfGroup(groupId); 20 | // Only allow writes on your user. Or allow author to perform writes. 21 | allow delete: if request.auth.uid == userId; 22 | } 23 | 24 | match /messages/{messageId} { 25 | allow read, write: if userIsMemberOfGroup(groupId); 26 | } 27 | } 28 | } 29 | 30 | // TODO: For security reasons we should probably move private user data in to a private sub collection. 31 | match /users/{userId} { 32 | allow update: if request.auth.uid == userId; 33 | allow read, create: if request.auth.uid != null; 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /firebase/functions/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /firebase/functions/admin.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | 3 | const admin = require('firebase-admin'); 4 | admin.initializeApp(functions.config().firebase); 5 | 6 | const db = admin.firestore(); 7 | 8 | module.exports = { 9 | db, 10 | admin, 11 | } 12 | -------------------------------------------------------------------------------- /firebase/functions/channel-flagChannelUnread.js: -------------------------------------------------------------------------------- 1 | const { db } = require('./admin'); 2 | 3 | /** 4 | * Sets `hasUpdates` flag for user in channel to false. 5 | * This is used to allow the channels listener on client-side 6 | * to update its list accordingly. 7 | */ 8 | const flagChannelUnread = async (groupdId, channelId, userId) => { 9 | try { 10 | await db 11 | .collection("/groups/") 12 | .doc(groupdId) 13 | .collection("/channels/") 14 | .doc(channelId) 15 | .collection("/users/").doc(userId).update({ 16 | hasUpdates: true 17 | }); 18 | console.log("Updated has updates for user: " + userId); 19 | } catch (error) { 20 | console.error("Error writing document: ", error); 21 | } 22 | } 23 | 24 | module.exports = flagChannelUnread; 25 | -------------------------------------------------------------------------------- /firebase/functions/channel-remove-user.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const { updateCalendarUser } = require('./calendar-update'); 3 | const { getChannel } = require('./channel-util'); 4 | const updateLatestActivityForChannel = require('./channel-updatedAt'); 5 | const { db } = require('./admin'); 6 | 7 | /** 8 | * This function is triggered when a user leaves a channel. 9 | * 10 | * @type {CloudFunction} 11 | **/ 12 | 13 | const deleteChannelUser = functions 14 | .region('europe-west1') 15 | .firestore 16 | .document('/groups/{groupId}/channels/{channelId}/users/{userId}') 17 | .onDelete(async (snap, context) => { 18 | // Remove user from calendar entry 19 | await updateCalendarUser(context.params.groupId, context.params.channelId, context.params.userId, true); 20 | 21 | // System message 22 | const channel = await getChannel(context.params.groupId, context.params.channelId); 23 | 24 | if (channel.data() == null) { 25 | return; 26 | } 27 | 28 | await updateLatestActivityForChannel(context.params.groupId, context.params.channelId, Date.now()); 29 | 30 | await db.collection("/users/") 31 | .doc(context.params.userId) 32 | .get() 33 | .then(snapshot => { 34 | return db.collection(`/groups/${context.params.groupId}/channels/${context.params.channelId}/messages/`) 35 | .add({ 36 | body: channel.data().type == 'EVENT' ? `${snapshot.data().name} {LEFT_EVENT}` : `${snapshot.data().name} {LEFT_CHANNEL}`, 37 | type: "SYSTEM", 38 | timestamp: `${Date.now()}`, 39 | author: { email: "system", name: "system" } 40 | }); 41 | }); 42 | }); 43 | 44 | module.exports = deleteChannelUser; 45 | -------------------------------------------------------------------------------- /firebase/functions/channel-remove.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const { deleteCalendarEntry } = require('./calendar-update'); 3 | 4 | const deleteChannel = functions 5 | .region('europe-west1') 6 | .firestore 7 | .document('/groups/{groupId}/channels/{channelId}') 8 | .onDelete(async (snap, context) => { 9 | return await deleteCalendarEntry(context.params.groupId, context.params.channelId); 10 | }); 11 | 12 | module.exports = deleteChannel; 13 | -------------------------------------------------------------------------------- /firebase/functions/channel-updatedAt.js: -------------------------------------------------------------------------------- 1 | const { db } = require('./admin'); 2 | 3 | /** 4 | * Sets `updatedAt` for channel to last activity's timestamp. 5 | */ 6 | const updateLatestActivityForChannel = async (groupId, channelId, activityDate) => { 7 | try { 8 | await db 9 | .collection("/groups/") 10 | .doc(groupId) 11 | .collection("/channels/") 12 | .doc(channelId).update({ 13 | updatedAt: activityDate 14 | }) 15 | 16 | } catch (error) { 17 | console.error("Error updating channel timestamp: ", error); 18 | } 19 | } 20 | 21 | module.exports = updateLatestActivityForChannel; -------------------------------------------------------------------------------- /firebase/functions/channel-util.js: -------------------------------------------------------------------------------- 1 | const { db } = require('./admin'); 2 | 3 | const getChannel = async (groupId, channelId) => { 4 | return await db 5 | .collection(`/groups/${groupId}/channels/`) 6 | .doc(channelId) 7 | .get(); 8 | } 9 | 10 | const getChannelName = async (groupId, channelId) => { 11 | let channelDoc = await getChannel(groupId, channelId); 12 | return channelDoc.data().name 13 | }; 14 | 15 | const sendSystemMessage = async (groupId, channelId, body) => { 16 | await db.collection(`/groups/${groupId}/channels/${channelId}/messages/`).add({ 17 | body: body, 18 | type: "SYSTEM", 19 | timestamp: `${Date.now()}`, 20 | author: { email: "system", name: "system" } 21 | }); 22 | }; 23 | 24 | 25 | module.exports = { getChannel, getChannelName, sendSystemMessage }; 26 | -------------------------------------------------------------------------------- /firebase/functions/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = Object.freeze({ 2 | CHANNEL_EVENT_TYPE: 'EVENT', 3 | CHANNEL_VISIBILITY_OPEN: 'OPEN', 4 | PUSH_LOCALE_DE: 'de', 5 | RSVP_MAYBE: 'MAYBE', 6 | RSVP_YES: 'YES', 7 | EVENT_EDIT_TIME: 'EVENT_EDIT_TIME', 8 | EVENT_EDIT_VENUE: 'EVENT_EDIT_VENUE', 9 | EVENT_EDIT_DESCRIPTION: 'EVENT_EDIT_DESCRIPTION', 10 | }); -------------------------------------------------------------------------------- /firebase/functions/group-update.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const { updateCalendarGroupName } = require('./calendar-update'); 3 | 4 | const updatedGroup = functions 5 | .region('europe-west1') 6 | .firestore 7 | .document('/groups/{groupId}') 8 | .onUpdate(async (change, context) => { 9 | const groupAfter = change.after.data(); 10 | 11 | // Update Calendar 12 | await updateCalendarGroupName(context.params.groupId, groupAfter.name); 13 | }); 14 | 15 | module.exports = updatedGroup; -------------------------------------------------------------------------------- /firebase/functions/group-util.js: -------------------------------------------------------------------------------- 1 | const { db } = require('./admin'); 2 | 3 | const getAllGroups = async () => { 4 | let groupDocs = await db 5 | .collection('groups') 6 | .get(); 7 | 8 | if (groupDocs.empty) { 9 | console.log('Could not find any groups'); 10 | return null; 11 | } 12 | 13 | return groupDocs; 14 | } 15 | 16 | const getGroupName = async (groupId) => { 17 | let groupDoc = await db 18 | .collection('groups') 19 | .doc(groupId) 20 | .get(); 21 | 22 | return groupDoc.data().name; 23 | } 24 | 25 | module.exports = { getGroupName, getAllGroups }; 26 | -------------------------------------------------------------------------------- /firebase/functions/index.js: -------------------------------------------------------------------------------- 1 | const updatedMessages = require('./message-update'); 2 | const newMessages = require('./message-new'); 3 | const newChannel = require('./channel-new'); 4 | const editChannel = require('./channel-edit'); 5 | const rsvpUpdate = require('./rsvp-update'); 6 | const newUser = require('./user-new'); 7 | const { notifyAboutUpcomingEventsToday, 8 | notifyAboutUpcomingEventsTomorrow } = require('./cron/event-notify-about-upcoming-event'); 9 | const newChannelUser = require('./channel-new-user'); 10 | const deleteChannelUser = require('./channel-remove-user'); 11 | const updatedGroup = require('./group-update'); 12 | const deleteChannel = require('./channel-remove'); 13 | 14 | exports.newUser = newUser; 15 | exports.newChannelUser = newChannelUser; 16 | exports.deleteChannelUser = deleteChannelUser; 17 | 18 | exports.newChannel = newChannel; 19 | exports.editChannel = editChannel; 20 | exports.deleteChannel = deleteChannel; 21 | 22 | exports.newMessages = newMessages; 23 | exports.updatedMessages = updatedMessages; 24 | 25 | exports.rsvpUpdate = rsvpUpdate; 26 | 27 | exports.updatedGroup = updatedGroup; 28 | 29 | /// Crons make us of the scheduling APIs in firebase. 30 | /// These APIs aren't available on the free Spark Plan. 31 | /// To deploy crons you'll need to enable the `Blaze Plan` first. 32 | /* 33 | // Crons 34 | exports.notifyAboutUpcomingEventsToday = notifyAboutUpcomingEventsToday; 35 | exports.notifyAboutUpcomingEventsTomorrow = notifyAboutUpcomingEventsTomorrow; 36 | */ 37 | -------------------------------------------------------------------------------- /firebase/functions/message-get.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create the push notification payload JSON, include the user token 3 | * 4 | * API v1 reference: https://firebase.google.com/docs/reference/fcm/rest/v1/projects.messages 5 | * 6 | */ 7 | const getMessage = (token, title, body, data, apns, android) => { 8 | return { 9 | token: token, 10 | notification: { 11 | title: title, 12 | body: body 13 | }, 14 | data: data, 15 | apns: apns, 16 | android: android 17 | }; 18 | } 19 | 20 | module.exports = getMessage; 21 | -------------------------------------------------------------------------------- /firebase/functions/message-new.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | 3 | const updateLatestActivityForChannel = require('./channel-updatedAt'); 4 | const userGroupUpdate = require('./user-groupUpdate'); 5 | const flagChannelUnread = require('./channel-flagChannelUnread'); 6 | const { usersInChannel, getUser } = require('./user-util'); 7 | const { sendNewMessagePush } = require('./push-send'); 8 | 9 | /** 10 | * This function is triggered when a new message document is created 11 | * 12 | * @type {CloudFunction} 13 | */ 14 | const newMessages = functions 15 | .region('europe-west1') 16 | .firestore 17 | .document('/groups/{groupId}/channels/{channelId}/messages/{messageId}') 18 | .onCreate(async (snap, context) => { 19 | // Obtain the newly created message 20 | const message = snap.data(); 21 | const path = snap.ref.path; 22 | const body = message.body; 23 | const authorId = message.author; 24 | const attachment = message.attachment; 25 | 26 | if (message.type != "USER") { 27 | console.log('System message. Ignoring'); 28 | return; 29 | } 30 | 31 | console.log(`Received ${body} with attachment: ${attachment}`); 32 | 33 | // get the list of users that joined the channel 34 | const users = await usersInChannel(context.params.groupId, context.params.channelId); 35 | 36 | // get the message author, we need their name 37 | const author = await getUser(authorId) ; 38 | 39 | // for each user in the channel, get the id 40 | for (const userSnapshot of users.docs) { 41 | let uid = userSnapshot.data().uid; 42 | // Only send notification or flag channel as unread if user is not author 43 | if (authorId !== uid) { 44 | await userGroupUpdate(context.params.groupId, uid, context.params.channelId); 45 | await flagChannelUnread(context.params.groupId, context.params.channelId, uid); 46 | await sendNewMessagePush(uid, body, attachment, context, author.data()); 47 | } 48 | } 49 | 50 | await updateLatestActivityForChannel(context.params.groupId, context.params.channelId, message.timestamp); 51 | }); 52 | 53 | module.exports = newMessages; 54 | -------------------------------------------------------------------------------- /firebase/functions/message-update.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const sendPushForNewReaction = require('./reaction-push'); 3 | 4 | const updatedMessages = functions 5 | .region('europe-west1') 6 | .firestore 7 | .document('/groups/{groupId}/channels/{channelId}/messages/{messageId}') 8 | .onUpdate(async (change, context) => { 9 | const messageBefore = change.before.data(); 10 | const messageAfter = change.after.data(); 11 | const authorUid = messageAfter.author; 12 | 13 | await sendPushForNewReaction(messageBefore, messageAfter, authorUid, context); 14 | 15 | }); 16 | 17 | module.exports = updatedMessages; 18 | -------------------------------------------------------------------------------- /firebase/functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "description": "Cloud Functions for Firebase", 4 | "scripts": { 5 | "lint": "./node_modules/.bin/eslint --max-warnings=0 .", 6 | "serve": "firebase serve --only functions", 7 | "shell": "firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "engines": { 13 | "node": "8" 14 | }, 15 | "dependencies": { 16 | "firebase-admin": "^8.4.0", 17 | "firebase-functions": "^3.2.0" 18 | }, 19 | "devDependencies": { 20 | "eslint": "^4.13.1", 21 | "eslint-plugin-promise": "^3.6.0", 22 | "firebase-functions-test": "^0.1.6" 23 | }, 24 | "private": true 25 | } 26 | -------------------------------------------------------------------------------- /firebase/functions/user-groupUpdate.js: -------------------------------------------------------------------------------- 1 | const { db, admin } = require('./admin'); 2 | 3 | /** 4 | * Performs two updates: 5 | * 1. Updates / Sets `updatedGroups` user `userId` with `groupId`. This is used to build a map of unread channels for a group on the client. 6 | * 2. Adds key `groupId` with value `channelId` of all channels containing updates. 7 | * This is used to highlight that there are yet unread updates. 8 | */ 9 | const userGroupUpdate = async (groupId, userId, channelId) => { 10 | try { 11 | const userDocRef = db.collection("/users/").doc(userId); 12 | await userDocRef.update({ 13 | updatedGroups: admin.firestore.FieldValue.arrayUnion(groupId), 14 | [groupId]: admin.firestore.FieldValue.arrayUnion(channelId) 15 | }) 16 | } catch (error) { 17 | console.error("Error updating user for group update: ", error); 18 | } 19 | } 20 | 21 | module.exports = userGroupUpdate; -------------------------------------------------------------------------------- /firebase/functions/user-new.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions'); 2 | const { db } = require('./admin'); 3 | 4 | const newUser = functions 5 | .region('europe-west1') 6 | .firestore 7 | .document('/users/{userId}') 8 | .onCreate(async (snapshot, context) => { 9 | const allGroups = await db.collection("/groups/") 10 | .where("members", "array-contains", context.params.userId) 11 | .get(); 12 | 13 | var groupIds = []; 14 | for(const groupSnapshot of allGroups.docs) { 15 | groupIds.push(groupSnapshot.id); 16 | } 17 | 18 | // Set all "joinedGroups" according to "members" in each group 19 | snapshot.ref.update({ 20 | joinedGroups : groupIds 21 | }); 22 | }); 23 | 24 | module.exports = newUser; -------------------------------------------------------------------------------- /firebase/functions/user-util.js: -------------------------------------------------------------------------------- 1 | const { db } = require('./admin'); 2 | 3 | const getUsersInGroup = async (groupId) => { 4 | try { 5 | return await db.collection("users") 6 | .where("joinedGroups", "array-contains", groupId) 7 | .get() 8 | } catch (error) { 9 | console.log(`Error getting users for group: ${groupId}`); 10 | } 11 | } 12 | 13 | const getUser = (uid) => { 14 | try { 15 | return db 16 | .collection('users') 17 | .doc(uid) 18 | .get(); 19 | } catch (error) { 20 | console.error("Error getting user: ", error); 21 | } 22 | } 23 | 24 | const usersInChannel = async (groupId, channelId) => { 25 | return await db 26 | .collection(`/groups/${groupId}/channels/${channelId}/users`) 27 | .get(); 28 | } 29 | 30 | module.exports = { getUser, usersInChannel, getUsersInGroup }; 31 | -------------------------------------------------------------------------------- /firebase/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "requires": true, 3 | "lockfileVersion": 1, 4 | "dependencies": { 5 | "moment": { 6 | "version": "2.24.0", 7 | "resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz", 8 | "integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /firebase/scripts/group-create.js: -------------------------------------------------------------------------------- 1 | const admin = require('firebase-admin'); 2 | 3 | var serviceAccount = require("../your-firebase-sdk-config-file.json"); 4 | 5 | admin.initializeApp({ 6 | credential: admin.credential.cert(serviceAccount), 7 | databaseURL: "https://your-firebase-project.firebaseio.com" 8 | }); 9 | 10 | const db = admin.firestore(); 11 | 12 | (async () => { 13 | try { 14 | 15 | // Get all users of the database 16 | const users = await db.collection('/users').listDocuments(); 17 | const members = []; 18 | for (let i = 0; i < users.length; i++) { 19 | members[i] = (await users[i].get()).data().uid; 20 | } 21 | 22 | // create a new 'dev' group 23 | const group = await db.collection('/groups').add({ 24 | 'abbreviation': 'DV', 25 | 'color': 'fcba03', 26 | 'name': 'dev', 27 | 'members': members 28 | }); 29 | 30 | // add all users to the 'dev' group 31 | for (let i = 0; i < users.length; i++) { 32 | const joinedGroups = (await users[i].get()).data().joinedGroups; 33 | joinedGroups.push(group.id); 34 | users[i].update({ 35 | 'joinedGroups' : joinedGroups 36 | }); 37 | } 38 | 39 | // create a general channel 40 | db.collection('/groups') 41 | .doc(group.id) 42 | .collection('/channels') 43 | .add({ 44 | 'name': "general", 45 | 'type': "TOPIC", 46 | 'visibility': "OPEN" 47 | }) 48 | 49 | console.log(`Group with id ${group.id} created`); 50 | 51 | } catch (e) { 52 | console.log(e); 53 | } 54 | })(); 55 | -------------------------------------------------------------------------------- /firestore-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/firestore-1.png -------------------------------------------------------------------------------- /firestore-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/firestore-2.png -------------------------------------------------------------------------------- /firestore-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/firestore-3.png -------------------------------------------------------------------------------- /firestore-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/firestore-4.png -------------------------------------------------------------------------------- /fonts/Edmondsans-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/fonts/Edmondsans-Bold.otf -------------------------------------------------------------------------------- /fonts/Edmondsans-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/fonts/Edmondsans-Medium.otf -------------------------------------------------------------------------------- /fonts/Edmondsans-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/fonts/Edmondsans-Regular.otf -------------------------------------------------------------------------------- /fonts/Poppins-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/fonts/Poppins-ExtraBold.ttf -------------------------------------------------------------------------------- /fonts/Poppins-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/fonts/Poppins-Regular.ttf -------------------------------------------------------------------------------- /ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 8.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | # platform :ios, '9.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def parse_KV_file(file, separator='=') 14 | file_abs_path = File.expand_path(file) 15 | if !File.exists? file_abs_path 16 | return []; 17 | end 18 | pods_ary = [] 19 | skip_line_start_symbols = ["#", "/"] 20 | File.foreach(file_abs_path) { |line| 21 | next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } 22 | plugin = line.split(pattern=separator) 23 | if plugin.length == 2 24 | podname = plugin[0].strip() 25 | path = plugin[1].strip() 26 | podpath = File.expand_path("#{path}", file_abs_path) 27 | pods_ary.push({:name => podname, :path => podpath}); 28 | else 29 | puts "Invalid plugin specification: #{line}" 30 | end 31 | } 32 | return pods_ary 33 | end 34 | 35 | target 'Runner' do 36 | use_frameworks! 37 | 38 | # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock 39 | # referring to absolute paths on developers' machines. 40 | system('rm -rf .symlinks') 41 | system('mkdir -p .symlinks/plugins') 42 | 43 | # Flutter Pods 44 | generated_xcode_build_settings = parse_KV_file('./Flutter/Generated.xcconfig') 45 | if generated_xcode_build_settings.empty? 46 | puts "Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." 47 | end 48 | generated_xcode_build_settings.map { |p| 49 | if p[:name] == 'FLUTTER_FRAMEWORK_DIR' 50 | symlink = File.join('.symlinks', 'flutter') 51 | File.symlink(File.dirname(p[:path]), symlink) 52 | pod 'Flutter', :path => File.join(symlink, File.basename(p[:path])) 53 | end 54 | } 55 | 56 | # Plugin Pods 57 | plugin_pods = parse_KV_file('../.flutter-plugins') 58 | plugin_pods.map { |p| 59 | symlink = File.join('.symlinks', 'plugins', p[:name]) 60 | File.symlink(p[:path], symlink) 61 | pod p[:name], :path => File.join(symlink, 'ios') 62 | } 63 | end 64 | 65 | post_install do |installer| 66 | installer.pods_project.targets.each do |target| 67 | target.build_configurations.each do |config| 68 | config.build_settings['ENABLE_BITCODE'] = 'NO' 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildSystemType 6 | Original 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @UIApplicationMain 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | 11 | UploadService.shared.configureFlutterHandler(flutterBinaryMessenger: window.rootViewController as! FlutterBinaryMessenger) 12 | PermissionService.shared.configureFlutterHandler(flutterBinaryMessenger: window.rootViewController as! FlutterBinaryMessenger) 13 | 14 | GeneratedPluginRegistrant.register(with: self) 15 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-1.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small20.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x-1.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x-1.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-40@3x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-41.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Spotlight-42.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-iPadPro@2x.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/AppIcon.appiconset/timy-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/AppIcon.appiconset/timy-icon.png -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "splash screen.pdf" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /ios/Runner/Assets.xcassets/LaunchImage.imageset/splash screen.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/ios/Runner/Assets.xcassets/LaunchImage.imageset/splash screen.pdf -------------------------------------------------------------------------------- /ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /ios/Runner/Info-dev.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | Timy | dev 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleLocalizations 16 | 17 | en 18 | de 19 | 20 | ITSAppUsesNonExemptEncryption 21 | 22 | CFBundleName 23 | Timy 24 | CFBundlePackageType 25 | APPL 26 | CFBundleShortVersionString 27 | $(FLUTTER_BUILD_NAME) 28 | CFBundleSignature 29 | ???? 30 | CFBundleVersion 31 | $(FLUTTER_BUILD_NUMBER) 32 | LSRequiresIPhoneOS 33 | 34 | NSCameraUsageDescription 35 | The user can upload pictures or video as message attachments 36 | NSMicrophoneUsageDescription 37 | The user can upload videos as message attachments 38 | NSPhotoLibraryUsageDescription 39 | The user can upload pictures as message attachments 40 | UIBackgroundModes 41 | 42 | remote-notification 43 | 44 | UILaunchStoryboardName 45 | LaunchScreen 46 | UIMainStoryboardFile 47 | Main 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | UIViewControllerBasedStatusBarAppearance 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleDisplayName 8 | Timy 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleLocalizations 16 | 17 | en 18 | de 19 | 20 | ITSAppUsesNonExemptEncryption 21 | CFBundleName 22 | Timy 23 | CFBundlePackageType 24 | APPL 25 | CFBundleShortVersionString 26 | $(FLUTTER_BUILD_NAME) 27 | CFBundleSignature 28 | ???? 29 | CFBundleVersion 30 | $(FLUTTER_BUILD_NUMBER) 31 | LSRequiresIPhoneOS 32 | 33 | NSCameraUsageDescription 34 | The user can upload pictures or video as message attachments 35 | NSMicrophoneUsageDescription 36 | The user can upload videos as message attachments 37 | NSPhotoLibraryUsageDescription 38 | The user can upload pictures as message attachments 39 | UIBackgroundModes 40 | 41 | remote-notification 42 | 43 | UILaunchStoryboardName 44 | LaunchScreen 45 | UIMainStoryboardFile 46 | Main 47 | UISupportedInterfaceOrientations 48 | 49 | UIInterfaceOrientationPortrait 50 | UIInterfaceOrientationLandscapeLeft 51 | UIInterfaceOrientationLandscapeRight 52 | 53 | UISupportedInterfaceOrientations~ipad 54 | 55 | UIInterfaceOrientationPortrait 56 | UIInterfaceOrientationPortraitUpsideDown 57 | UIInterfaceOrientationLandscapeLeft 58 | UIInterfaceOrientationLandscapeRight 59 | 60 | UIViewControllerBasedStatusBarAppearance 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" -------------------------------------------------------------------------------- /ios/Runner/Runner-dev.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/Runner.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | production 7 | 8 | 9 | -------------------------------------------------------------------------------- /ios/Runner/de.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | NSCameraUsageDescription = "Für Bild- und Videonachrichten benötigten wir Zugriff auf deine Kamera"; 2 | NSMicrophoneUsageDescription = "Dein Mikrofon wird für Videonachrichten benötigt"; 3 | NSPhotoLibraryUsageDescription = "Für Bildnachrichten benötigen wir Zugriff auf deine Fotos"; 4 | -------------------------------------------------------------------------------- /ios/Runner/de.lproj/LaunchScreen.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ios/Runner/de.lproj/Main.strings: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ios/Runner/en.lproj/InfoPlist.strings: -------------------------------------------------------------------------------- 1 | NSCameraUsageDescription = "The user can upload pictures or video as message attachments"; 2 | NSMicrophoneUsageDescription = "The user can upload videos as message attachments"; 3 | NSPhotoLibraryUsageDescription = "The user can upload pictures as message attachments"; 4 | -------------------------------------------------------------------------------- /lib/cupertinoLocalizationDelegate.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/cupertino.dart"; 2 | 3 | // This delegate fixes an issue which caused alerts on iOS to fail. 4 | // https://github.com/flutter/flutter/issues/23047 5 | 6 | class FallbackCupertinoLocalisationsDelegate 7 | extends LocalizationsDelegate { 8 | const FallbackCupertinoLocalisationsDelegate(); 9 | 10 | @override 11 | bool isSupported(Locale locale) => true; 12 | 13 | @override 14 | Future load(Locale locale) => 15 | DefaultCupertinoLocalizations.load(locale); 16 | 17 | @override 18 | bool shouldReload(FallbackCupertinoLocalisationsDelegate old) => false; 19 | } 20 | -------------------------------------------------------------------------------- /lib/data/calendar_repository.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/data/firestore_paths.dart"; 2 | import "package:circles_app/model/calendar_entry.dart"; 3 | import "package:cloud_firestore/cloud_firestore.dart"; 4 | 5 | class CalendarRepository { 6 | final Firestore _firestore; 7 | const CalendarRepository(this._firestore); 8 | 9 | Future> getCalendarEntries(String userId) async { 10 | final snapshot = await _firestore 11 | .collection(FirestorePaths.PATH_CALENDAR) 12 | .where(_Constants.USERS, arrayContains: userId) 13 | .orderBy(_Constants.EVENTDATE, descending: false) 14 | .limit(100) 15 | .getDocuments(); 16 | 17 | return snapshot.documents.map((d) => _fromDoc(d)).toList(); 18 | } 19 | 20 | static CalendarEntry _fromDoc(DocumentSnapshot doc) { 21 | return CalendarEntry((calendarEntry) => calendarEntry 22 | ..channelId = doc[_Constants.CHANNELID] 23 | ..groupId = doc[_Constants.GROUPID] 24 | ..groupName = doc[_Constants.GROUPNAME] 25 | ..channelName = doc[_Constants.CHANNELNAME] 26 | ..eventDate = doc[_Constants.EVENTDATE].toDate() 27 | ..hasStartTime = doc[_Constants.HASSTARTTIME] != null 28 | ? doc[_Constants.HASSTARTTIME] 29 | : false); 30 | } 31 | } 32 | 33 | class _Constants { 34 | static const String CHANNELID = "channel_id"; 35 | static const String GROUPID = "group_id"; 36 | static const String GROUPNAME = "group_name"; 37 | static const String CHANNELNAME = "channel_name"; 38 | static const String EVENTDATE = "event_date"; 39 | static const String HASSTARTTIME = "has_start_time"; 40 | static const String USERS = "users"; 41 | } 42 | -------------------------------------------------------------------------------- /lib/data/file_repository.dart: -------------------------------------------------------------------------------- 1 | import "dart:io"; 2 | 3 | import "package:firebase_storage/firebase_storage.dart"; 4 | 5 | class FileRepository { 6 | final FirebaseStorage _firebaseStorage; 7 | 8 | FileRepository(this._firebaseStorage); 9 | 10 | Future uploadFile(File file) async { 11 | final String fileName = DateTime.now().millisecondsSinceEpoch.toString(); 12 | final StorageReference reference = _firebaseStorage.ref().child(fileName); 13 | final StorageUploadTask uploadTask = reference.putFile(file); 14 | final StorageTaskSnapshot storageTaskSnapshot = await uploadTask.onComplete; 15 | return storageTaskSnapshot.ref.getDownloadURL(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/data/firestore_paths.dart: -------------------------------------------------------------------------------- 1 | class FirestorePaths { 2 | static const PATH_GROUPS = "groups"; 3 | static const PATH_CHANNELS = "channels"; 4 | static const PATH_MESSAGES = "messages"; 5 | static const PATH_USERS = "users"; 6 | static const PATH_CALENDAR = "calendar"; 7 | 8 | static String groupPath(String groupId) { 9 | return "$PATH_GROUPS/$groupId"; 10 | } 11 | 12 | static String channelsPath(String groupId) { 13 | return "$PATH_GROUPS/$groupId/$PATH_CHANNELS"; 14 | } 15 | 16 | static String channelPath(String groupId, String channelId) { 17 | return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId"; 18 | } 19 | 20 | static String channelUsersPath(String groupId, String channelId) { 21 | return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId/$PATH_USERS"; 22 | } 23 | 24 | static String messagesPath(String groupId, String channelId) { 25 | return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId/$PATH_MESSAGES"; 26 | } 27 | 28 | static String messagePath( 29 | String groupId, 30 | String channelId, 31 | String messageId, 32 | ) { 33 | return "$PATH_GROUPS/$groupId/$PATH_CHANNELS/$channelId/$PATH_MESSAGES/$messageId"; 34 | } 35 | 36 | static String userPath(String userId) { 37 | return "$PATH_USERS/$userId"; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/data/group_repository.dart: -------------------------------------------------------------------------------- 1 | import "dart:core"; 2 | import "package:circles_app/data/firestore_paths.dart"; 3 | import "package:circles_app/model/group.dart"; 4 | import "package:cloud_firestore/cloud_firestore.dart"; 5 | 6 | class GroupRepository { 7 | static const String NAME = "name"; 8 | static const String ABBREVIATION = "abbreviation"; 9 | static const String IMAGE = "image"; 10 | static const String COLOR = "color"; 11 | static const String MEMBERS = "members"; 12 | 13 | final Firestore firestore; 14 | 15 | const GroupRepository(this.firestore); 16 | 17 | Future getGroup(String id) async { 18 | final doc = await firestore.document(FirestorePaths.groupPath(id)).get(); 19 | return fromDoc(doc); 20 | } 21 | 22 | Stream> getGroupStream(userId) { 23 | return firestore 24 | .collection(FirestorePaths.PATH_GROUPS) 25 | .where(MEMBERS, arrayContains: userId) 26 | .snapshots() 27 | .map((snapShot) { 28 | return snapShot.documents.map((doc) => fromDoc(doc)).toList(); 29 | }); 30 | } 31 | 32 | static Group fromDoc(DocumentSnapshot doc) { 33 | return Group((c) => c 34 | ..id = doc.documentID 35 | ..name = doc[NAME] 36 | ..image = doc[IMAGE] 37 | ..abbreviation = doc[ABBREVIATION] 38 | ..hexColor = doc[COLOR]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/domain/redux/app_actions.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/group.dart"; 2 | import "package:meta/meta.dart"; 3 | 4 | /// Actions are payloads of information that send data from your application to 5 | /// your store. They are the only source of information for the store. 6 | /// 7 | /// They are PODOs (Plain Old Dart Objects). 8 | /// 9 | class ConnectToDataSource { 10 | @override 11 | String toString() { 12 | return "ConnectToDataSource{}"; 13 | } 14 | } 15 | 16 | @immutable 17 | class OnGroupsLoaded { 18 | final List groups; 19 | 20 | const OnGroupsLoaded(this.groups); 21 | 22 | @override 23 | String toString() { 24 | return "OnGroupsLoaded{groups: $groups}"; 25 | } 26 | } 27 | 28 | @immutable 29 | class SelectGroup { 30 | final String groupId; 31 | 32 | const SelectGroup(this.groupId); 33 | 34 | @override 35 | String toString() { 36 | return "SelectGroup{groupId: $groupId}"; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/domain/redux/app_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/domain/redux/app_actions.dart"; 3 | import "package:circles_app/domain/redux/app_state.dart"; 4 | import "package:circles_app/domain/redux/authentication/auth_reducer.dart"; 5 | import "package:circles_app/domain/redux/calendar/calendar_reducer.dart"; 6 | import "package:circles_app/domain/redux/channel/channel_reducer.dart"; 7 | import "package:circles_app/domain/redux/ui/ui_reducer.dart"; 8 | import "package:circles_app/model/channel_state.dart"; 9 | import "package:circles_app/domain/redux/message/message_reducer.dart"; 10 | import "package:circles_app/domain/redux/push/push_reducer.dart"; 11 | import "package:circles_app/domain/redux/user/user_reducer.dart"; 12 | import "package:circles_app/model/group.dart"; 13 | import "package:redux/redux.dart"; 14 | 15 | /// Reducers specify how the application"s state changes in response to actions 16 | /// sent to the store. 17 | /// 18 | /// Each reducer returns a new [AppState]. 19 | /// 20 | final appReducer = combineReducers([ 21 | TypedReducer(_onGroupsLoaded), 22 | TypedReducer(_onSelectGroup), 23 | ...authReducers, 24 | ...userReducers, 25 | ...calendarReducer, 26 | ...channelReducers, 27 | ...messageReducers, 28 | ...pushReducers, 29 | ...uiReducers, 30 | ]); 31 | 32 | AppState _onGroupsLoaded(AppState state, OnGroupsLoaded action) { 33 | if (action.groups.isNotEmpty) { 34 | final selectedGroup = state.selectedGroupId; 35 | final Map groups = Map.fromIterable( 36 | action.groups, 37 | key: (item) => item.id, 38 | value: (item) => item, 39 | ); 40 | return state.rebuild((a) => a 41 | ..selectedGroupId = selectedGroup 42 | ..groups = MapBuilder(groups)); 43 | } else { 44 | return state.rebuild((a) => a 45 | ..channelState = ChannelState.init().toBuilder() 46 | ..groups = MapBuilder()); 47 | } 48 | } 49 | 50 | AppState _onSelectGroup(AppState state, SelectGroup action) { 51 | return state.rebuild((a) => a..selectedGroupId = action.groupId); 52 | } 53 | -------------------------------------------------------------------------------- /lib/domain/redux/app_selector.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/model/channel.dart"; 3 | 4 | Channel getSelectedChannel(AppState state) { 5 | if (state.selectedGroupId == null) return null; 6 | if (state.channelState.selectedChannel == null) return null; 7 | return state.groups[state.selectedGroupId] 8 | .channels[state.channelState.selectedChannel]; 9 | } 10 | -------------------------------------------------------------------------------- /lib/domain/redux/app_state.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | import "package:circles_app/domain/redux/ui/ui_state.dart"; 4 | import "package:circles_app/model/calendar_entry.dart"; 5 | import "package:circles_app/model/channel_state.dart"; 6 | import "package:circles_app/model/group.dart"; 7 | import "package:circles_app/model/in_app_notification.dart"; 8 | import "package:circles_app/model/message.dart"; 9 | import "package:circles_app/model/user.dart"; 10 | 11 | // ignore: prefer_double_quotes 12 | part 'app_state.g.dart'; 13 | 14 | /// This class holds the whole application state. 15 | /// Which can include: 16 | /// - user calendar 17 | /// - current user profile 18 | /// - joined channels 19 | /// - received messages 20 | /// - etc. 21 | /// 22 | abstract class AppState implements Built { 23 | BuiltList get userCalendar; 24 | 25 | BuiltMap get groups; 26 | 27 | @nullable 28 | String get selectedGroupId; 29 | 30 | @nullable 31 | User get user; 32 | 33 | BuiltList get groupUsers; 34 | 35 | ChannelState get channelState; 36 | 37 | BuiltList get messagesOnScreen; 38 | 39 | @nullable 40 | String get fcmToken; 41 | 42 | @nullable 43 | InAppNotification get inAppNotification; 44 | 45 | UiState get uiState; 46 | 47 | AppState._(); 48 | 49 | factory AppState([void Function(AppStateBuilder) updates]) = _$AppState; 50 | 51 | factory AppState.init() => AppState((a) => a 52 | ..groups = MapBuilder() 53 | ..channelState = ChannelState.init().toBuilder() 54 | ..messagesOnScreen = ListBuilder() 55 | ..groupUsers = ListBuilder() 56 | ..userCalendar = ListBuilder() 57 | ..uiState = UiState().toBuilder()); 58 | 59 | AppState clear() { 60 | // keep the temporal fcm token even when clearing state 61 | // so it can be set again on login. 62 | // 63 | // Add here anything else that also needs to be carried over. 64 | return AppState.init().rebuild((s) => s..fcmToken = fcmToken); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/domain/redux/attachment/attachment_actions.dart: -------------------------------------------------------------------------------- 1 | import "dart:io"; 2 | 3 | import "package:circles_app/model/user.dart"; 4 | import "package:flutter/cupertino.dart"; 5 | import "package:meta/meta.dart"; 6 | 7 | @immutable 8 | class NewMessageWithMultipleFilesAction { 9 | final List fileIdentifiers; // File paths in case of Android, localIdentifier in case of iOS multiselect & path in case of camera image 10 | final bool isPath; 11 | const NewMessageWithMultipleFilesAction(this.fileIdentifiers, this.isPath); 12 | } 13 | 14 | @immutable 15 | class ChangeAvatarAction { 16 | final File file; 17 | final User user; 18 | 19 | const ChangeAvatarAction({ 20 | @required this.file, 21 | @required this.user, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /lib/domain/redux/attachment/image_processor.dart: -------------------------------------------------------------------------------- 1 | import "dart:math"; 2 | 3 | import "package:flutter_native_image/flutter_native_image.dart"; 4 | import "dart:io"; 5 | 6 | const avatarSize = 200; 7 | 8 | class ImageProcessor { 9 | Future cropAndResizeAvatar(File file) async { 10 | final ImageProperties properties = 11 | await FlutterNativeImage.getImageProperties(file.path); 12 | final squareSize = min(properties.width, properties.height); 13 | final originX = ((properties.width / 2) - (squareSize / 2)).toInt(); 14 | final originY = ((properties.height / 2) - (squareSize / 2)).toInt(); 15 | final cropped = await FlutterNativeImage.cropImage( 16 | file.path, originX, originY, squareSize, squareSize); 17 | return await FlutterNativeImage.compressImage(cropped.path, 18 | quality: 95, targetWidth: avatarSize, targetHeight: avatarSize); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/domain/redux/authentication/auth_actions.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | import "package:circles_app/model/user.dart"; 3 | import "package:meta/meta.dart"; 4 | 5 | // Authentication 6 | class VerifyAuthenticationState {} 7 | 8 | class LogIn { 9 | final String email; 10 | final String password; 11 | final Completer completer; 12 | 13 | LogIn({this.email, this.password, Completer completer}) 14 | : completer = completer ?? Completer(); 15 | } 16 | 17 | @immutable 18 | class OnAuthenticated { 19 | final User user; 20 | 21 | const OnAuthenticated({@required this.user}); 22 | 23 | @override 24 | String toString() { 25 | return "OnAuthenticated{user: $user}"; 26 | } 27 | } 28 | 29 | class LogOutAction {} 30 | 31 | class OnLogoutSuccess { 32 | OnLogoutSuccess(); 33 | 34 | @override 35 | String toString() { 36 | return "LogOut{user: null}"; 37 | } 38 | } 39 | 40 | class OnLogoutFail { 41 | final dynamic error; 42 | 43 | OnLogoutFail(this.error); 44 | 45 | @override 46 | String toString() { 47 | return "OnLogoutFail{There was an error logging in: $error}"; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/domain/redux/authentication/auth_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/domain/redux/authentication/auth_actions.dart"; 3 | import "package:redux/redux.dart"; 4 | 5 | final authReducers = [ 6 | TypedReducer(_onAuthenticated), 7 | TypedReducer(_onLogout), 8 | ]; 9 | 10 | AppState _onAuthenticated(AppState state, OnAuthenticated action) { 11 | return state.rebuild((a) => a..user = action.user.toBuilder()); 12 | } 13 | 14 | AppState _onLogout(AppState state, OnLogoutSuccess action) { 15 | return state.clear(); 16 | } 17 | -------------------------------------------------------------------------------- /lib/domain/redux/calendar/calendar_actions.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/calendar_entry.dart"; 2 | import "package:flutter/foundation.dart"; 3 | 4 | @immutable 5 | class CalendarUpdatedAction { 6 | final List calendarEntries; 7 | 8 | const CalendarUpdatedAction({this.calendarEntries}); 9 | 10 | @override 11 | String toString() { 12 | return "CalendarUpdatedAction{calendarEntries: $calendarEntries}"; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/domain/redux/calendar/calendar_middleware.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/data/calendar_repository.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/calendar/calendar_actions.dart"; 4 | import "package:circles_app/domain/redux/channel/channel_actions.dart"; 5 | import "package:redux/redux.dart"; 6 | 7 | List> createCalendarMiddleware( 8 | CalendarRepository calendarRepository, 9 | ) { 10 | return [ 11 | TypedMiddleware(_loadCalendar( 12 | calendarRepository, 13 | )), 14 | ]; 15 | } 16 | 17 | /// Load calendarEntries when channel list updates 18 | void Function( 19 | Store store, 20 | OnChannelsLoaded action, 21 | NextDispatcher next, 22 | ) _loadCalendar( 23 | CalendarRepository calendarRepository, 24 | ) { 25 | return (store, action, next) async { 26 | next(action); 27 | 28 | final calendarEntries = 29 | await calendarRepository.getCalendarEntries(store.state.user.uid); 30 | 31 | store.dispatch( 32 | CalendarUpdatedAction(calendarEntries: calendarEntries), 33 | ); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /lib/domain/redux/calendar/calendar_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/calendar/calendar_actions.dart"; 4 | import "package:redux/redux.dart"; 5 | 6 | final calendarReducer = [ 7 | TypedReducer(_onCalendarUpdate), 8 | ]; 9 | 10 | AppState _onCalendarUpdate(AppState state, CalendarUpdatedAction action) { 11 | return state.rebuild((appState) => 12 | appState..userCalendar = ListBuilder(action.calendarEntries)); 13 | } 14 | -------------------------------------------------------------------------------- /lib/domain/redux/message/message_actions.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/message.dart"; 2 | import "package:meta/meta.dart"; 3 | 4 | @immutable 5 | class SendMessage { 6 | final String message; 7 | 8 | const SendMessage( 9 | this.message, 10 | ); 11 | 12 | @override 13 | String toString() { 14 | return "SendMessage{message: $message}"; 15 | } 16 | } 17 | 18 | @immutable 19 | class UpdateAllMessages { 20 | final List data; 21 | 22 | const UpdateAllMessages(this.data); 23 | } 24 | 25 | @immutable 26 | class DeleteMessage { 27 | final String messageId; 28 | 29 | const DeleteMessage(this.messageId); 30 | } 31 | 32 | @immutable 33 | class EmojiReaction { 34 | final String emoji; 35 | final String messageId; 36 | 37 | const EmojiReaction(this.messageId, this.emoji); 38 | } 39 | 40 | @immutable 41 | class RemoveEmojiReaction { 42 | final String messageId; 43 | 44 | const RemoveEmojiReaction(this.messageId); 45 | } 46 | -------------------------------------------------------------------------------- /lib/domain/redux/message/message_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/message/message_actions.dart"; 4 | import "package:redux/redux.dart"; 5 | 6 | final messageReducers = [ 7 | TypedReducer(_onMessageUpdated), 8 | ]; 9 | 10 | AppState _onMessageUpdated(AppState state, UpdateAllMessages action) { 11 | return state.rebuild((a) => a..messagesOnScreen = ListBuilder(action.data)); 12 | } 13 | -------------------------------------------------------------------------------- /lib/domain/redux/push/push_actions.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/in_app_notification.dart"; 2 | 3 | class UpdateUserTokenAction { 4 | final String token; 5 | 6 | UpdateUserTokenAction(this.token); 7 | } 8 | 9 | class OnPushNotificationOpenAction { 10 | final Map message; 11 | 12 | OnPushNotificationOpenAction(this.message); 13 | } 14 | 15 | class OnPushNotificationReceivedAction { 16 | final Map message; 17 | 18 | OnPushNotificationReceivedAction(this.message); 19 | } 20 | 21 | class ShowPushNotificationAction { 22 | InAppNotification inAppNotification; 23 | 24 | ShowPushNotificationAction(this.inAppNotification); 25 | 26 | @override 27 | bool operator ==(Object other) => 28 | identical(this, other) || 29 | other is ShowPushNotificationAction && 30 | runtimeType == other.runtimeType && 31 | inAppNotification == other.inAppNotification; 32 | 33 | @override 34 | int get hashCode => inAppNotification.hashCode; 35 | } 36 | 37 | class OnPushNotificationDismissedAction {} 38 | -------------------------------------------------------------------------------- /lib/domain/redux/push/push_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/domain/redux/push/push_actions.dart"; 3 | import "package:redux/redux.dart"; 4 | 5 | final pushReducers = [ 6 | TypedReducer(_updateUserAction), 7 | TypedReducer( 8 | _showPushNotificationAction), 9 | TypedReducer( 10 | _onPushNotificationDismissed), 11 | ]; 12 | 13 | AppState _updateUserAction(AppState state, UpdateUserTokenAction action) { 14 | return state.rebuild((s) => s..fcmToken = action.token); 15 | } 16 | 17 | AppState _showPushNotificationAction( 18 | AppState state, ShowPushNotificationAction action) { 19 | return state.rebuild( 20 | (s) => s..inAppNotification = action.inAppNotification.toBuilder()); 21 | } 22 | 23 | AppState _onPushNotificationDismissed( 24 | AppState state, 25 | OnPushNotificationDismissedAction action, 26 | ) { 27 | return state.rebuild((s) => s..inAppNotification = null); 28 | } 29 | -------------------------------------------------------------------------------- /lib/domain/redux/stream_subscriptions.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | import "package:circles_app/model/channel.dart"; 4 | import "package:circles_app/model/group.dart"; 5 | import "package:circles_app/model/message.dart"; 6 | import "package:circles_app/model/user.dart"; 7 | 8 | // App user 9 | StreamSubscription userUpdateSubscription; 10 | // List of user's groups 11 | StreamSubscription> groupsSubscription; 12 | // List of users of the current selected group 13 | StreamSubscription> groupUsersSubscription; 14 | // List of channels of the current selected group 15 | StreamSubscription> listOfChannelsSubscription; 16 | // Selected channel 17 | StreamSubscription selectedChannelSubscription; 18 | // Messages from selected channel 19 | StreamSubscription> messagesSubscription; 20 | 21 | /// Cancels all active subscriptions 22 | /// 23 | /// Called on successful logout. 24 | cancelAllSubscriptions() { 25 | userUpdateSubscription?.cancel(); 26 | groupsSubscription?.cancel(); 27 | groupUsersSubscription?.cancel(); 28 | listOfChannelsSubscription?.cancel(); 29 | selectedChannelSubscription?.cancel(); 30 | messagesSubscription?.cancel(); 31 | } -------------------------------------------------------------------------------- /lib/domain/redux/ui/ui_actions.dart: -------------------------------------------------------------------------------- 1 | import "package:meta/meta.dart"; 2 | 3 | class UpdatedChatDraftAction { 4 | final String text; 5 | final String groupId; 6 | final String channelId; 7 | 8 | const UpdatedChatDraftAction({ 9 | @required this.text, 10 | @required this.groupId, 11 | @required this.channelId, 12 | }); 13 | 14 | @override 15 | String toString() { 16 | return "UpdatedChatDraftAction{text: $text, groupId: $groupId, channelId: $channelId}"; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/domain/redux/ui/ui_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/domain/redux/ui/ui_actions.dart"; 3 | import "package:circles_app/domain/redux/ui/ui_state_selector.dart"; 4 | import "package:circles_app/util/logger.dart"; 5 | import "package:redux/redux.dart"; 6 | 7 | final uiReducers = [ 8 | TypedReducer(_onUpdatedChatDraft), 9 | ]; 10 | 11 | AppState _onUpdatedChatDraft(AppState state, UpdatedChatDraftAction action) { 12 | Logger.d(action.toString()); 13 | final out = state.rebuild( 14 | (s) => s 15 | ..uiState.update( 16 | (u) => updateInputDraft( 17 | state: u, 18 | groupId: action.groupId, 19 | channelId: action.channelId, 20 | value: action.text, 21 | ), 22 | ), 23 | ); 24 | Logger.d(out.uiState.toString()); 25 | return out; 26 | } 27 | -------------------------------------------------------------------------------- /lib/domain/redux/ui/ui_state.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | 4 | // ignore: prefer_double_quotes 5 | part 'ui_state.g.dart'; 6 | 7 | /// 8 | /// Store different UI related data (last selected channel, channel input text, etc.) 9 | /// 10 | /// 11 | abstract class UiState implements Built { 12 | 13 | 14 | // Group UI state per group id 15 | BuiltMap get groupUiState; 16 | 17 | UiState._(); 18 | factory UiState([void Function(UiStateBuilder) updates]) = _$UiState; 19 | } 20 | 21 | /// 22 | /// Store UI related data per group 23 | /// 24 | abstract class GroupUiState implements Built { 25 | // When a user changes groups, pick the last selected channel if present 26 | @nullable 27 | String get lastSelectedChannel; 28 | 29 | // Channel UI state per channel id 30 | BuiltMap get channelUiState; 31 | 32 | GroupUiState._(); 33 | factory GroupUiState([void Function(GroupUiStateBuilder) updates]) = _$GroupUiState; 34 | } 35 | 36 | /// 37 | /// Store UI related data per channel 38 | /// 39 | abstract class ChannelUiState implements Built { 40 | 41 | @nullable 42 | String get inputDraft; 43 | 44 | ChannelUiState._(); 45 | factory ChannelUiState([void Function(ChannelUiStateBuilder) updates]) = _$ChannelUiState; 46 | } 47 | -------------------------------------------------------------------------------- /lib/domain/redux/user/user_actions.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | import "package:circles_app/model/user.dart"; 4 | import "package:meta/meta.dart"; 5 | 6 | @immutable 7 | class UsersUpdateAction { 8 | final List users; 9 | 10 | const UsersUpdateAction(this.users); 11 | } 12 | 13 | @immutable 14 | class OnUserUpdateAction { 15 | final User user; 16 | 17 | const OnUserUpdateAction(this.user); 18 | } 19 | 20 | @immutable 21 | class UpdateUserLocaleAction { 22 | final String locale; 23 | 24 | const UpdateUserLocaleAction(this.locale); 25 | } 26 | 27 | @immutable 28 | class UpdateUserAction { 29 | final User user; 30 | final Completer completer; 31 | 32 | const UpdateUserAction(this.user, this.completer); 33 | } 34 | -------------------------------------------------------------------------------- /lib/domain/redux/user/user_reducer.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/user/user_actions.dart"; 4 | import "package:redux/redux.dart"; 5 | 6 | final userReducers = [ 7 | TypedReducer(_onUsersUpdate), 8 | TypedReducer(_onUserUpdate), 9 | ]; 10 | 11 | AppState _onUserUpdate(AppState state, OnUserUpdateAction action) { 12 | return state.rebuild((a) => a 13 | // Update the app user 14 | ..user = action.user.toBuilder() 15 | // Update the user in the groupUsers 16 | ..groupUsers.removeWhere((u) => u.uid == action.user.uid) 17 | ..groupUsers.add(action.user)); 18 | } 19 | 20 | AppState _onUsersUpdate(AppState state, UsersUpdateAction action) { 21 | return state.rebuild((a) => a..groupUsers = ListBuilder(action.users)); 22 | } 23 | 24 | -------------------------------------------------------------------------------- /lib/main.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_app.dart"; 2 | import "package:circles_app/util/logger.dart"; 3 | import "package:flutter/material.dart"; 4 | 5 | void main() { 6 | configureLogger(); 7 | runApp(CirclesApp()); 8 | } 9 | -------------------------------------------------------------------------------- /lib/model/calendar_entry.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | 3 | // ignore: prefer_double_quotes 4 | part 'calendar_entry.g.dart'; 5 | 6 | abstract class CalendarEntry implements Built { 7 | String get channelId; 8 | 9 | String get channelName; 10 | 11 | String get groupId; 12 | 13 | String get groupName; 14 | 15 | DateTime get eventDate; 16 | 17 | bool get hasStartTime; 18 | 19 | CalendarEntry._(); 20 | 21 | factory CalendarEntry([void Function(CalendarEntryBuilder) updates]) = _$CalendarEntry; 22 | } 23 | -------------------------------------------------------------------------------- /lib/model/channel_state.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | 3 | // ignore: prefer_double_quotes 4 | part 'channel_state.g.dart'; 5 | 6 | abstract class ChannelState 7 | implements Built { 8 | @nullable 9 | String get selectedChannel; 10 | 11 | bool get joinChannelFailed; 12 | 13 | ChannelState._(); 14 | 15 | factory ChannelState([void Function(ChannelStateBuilder) updates]) = 16 | _$ChannelState; 17 | 18 | factory ChannelState.init() => ChannelState((c) => c 19 | ..selectedChannel = null 20 | ..joinChannelFailed = false); 21 | } 22 | -------------------------------------------------------------------------------- /lib/model/group.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | import "package:circles_app/model/channel.dart"; 4 | 5 | // ignore: prefer_double_quotes 6 | part 'group.g.dart'; 7 | 8 | abstract class Group implements Built { 9 | String get id; 10 | 11 | String get name; 12 | 13 | String get hexColor; 14 | 15 | @nullable 16 | String get image; 17 | 18 | String get abbreviation; 19 | 20 | BuiltMap get channels; 21 | 22 | Group._(); 23 | 24 | factory Group([void Function(GroupBuilder) updates]) = _$Group; 25 | } 26 | -------------------------------------------------------------------------------- /lib/model/in_app_notification.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | import "package:circles_app/model/channel.dart"; 3 | 4 | // ignore: prefer_double_quotes 5 | part 'in_app_notification.g.dart'; 6 | 7 | abstract class InAppNotification implements Built { 8 | 9 | String get groupId; 10 | 11 | String get groupName; 12 | 13 | String get userName; 14 | 15 | String get message; 16 | 17 | Channel get channel; 18 | 19 | InAppNotification._(); 20 | factory InAppNotification([void Function(InAppNotificationBuilder) updates]) = _$InAppNotification; 21 | } -------------------------------------------------------------------------------- /lib/model/reaction.dart: -------------------------------------------------------------------------------- 1 | 2 | import "package:built_value/built_value.dart"; 3 | 4 | // ignore: prefer_double_quotes 5 | part 'reaction.g.dart'; 6 | 7 | abstract class Reaction implements Built { 8 | 9 | String get emoji; 10 | 11 | String get userId; 12 | 13 | String get userName; 14 | 15 | DateTime get timestamp; 16 | 17 | Reaction._(); 18 | factory Reaction([void Function(ReactionBuilder) updates]) = _$Reaction; 19 | } -------------------------------------------------------------------------------- /lib/model/user.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | 4 | // ignore: prefer_double_quotes 5 | part 'user.g.dart'; 6 | 7 | abstract class User implements Built { 8 | String get uid; 9 | 10 | String get email; 11 | 12 | String get name; 13 | 14 | @nullable 15 | String get status; 16 | 17 | // Keeps groupId : [channelId], marking the unread channels. 18 | @nullable 19 | BuiltMap get unreadUpdates; 20 | 21 | @nullable 22 | String get image; 23 | 24 | User._(); 25 | 26 | factory User([void Function(UserBuilder) updates]) = _$User; 27 | } 28 | 29 | class UserHelper { 30 | static List userIds(List userIds) { 31 | if (userIds == null) return []; 32 | return userIds.whereType().toList(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/native_channels/android_permission_channel.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/native_channels/ios_permission_channel.dart"; 2 | import "package:circles_app/util/logger.dart"; 3 | import "package:flutter/foundation.dart"; 4 | import "package:flutter/services.dart"; 5 | 6 | const ANDROID_PERMISSION_GRANTED = 0; 7 | 8 | class AndroidPermissionChannel { 9 | static const MethodChannel channel = 10 | MethodChannel("de.janoodle.timy/permission-android"); 11 | 12 | static Future requestPermission({ 13 | @required PermissionType permissionType 14 | }) async { 15 | final result = await channel.invokeMethod( 16 | "requestPermission", 17 | { 18 | "permissionType": _stringOf(permissionType), 19 | }, 20 | ); 21 | final int granted = result[_stringOf(permissionType)]; 22 | if (granted == ANDROID_PERMISSION_GRANTED) { 23 | Logger.d("Permission granted"); 24 | return true; 25 | } else { 26 | Logger.w("Permission denied for ${_stringOf(permissionType)}"); 27 | return false; 28 | } 29 | } 30 | 31 | static String _stringOf(PermissionType permissionType) { 32 | switch (permissionType) { 33 | case PermissionType.Photos: 34 | return "android.permission.READ_EXTERNAL_STORAGE"; 35 | default: 36 | return ""; 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /lib/native_channels/android_thumbnail_channel.dart: -------------------------------------------------------------------------------- 1 | import "dart:typed_data"; 2 | 3 | import "package:flutter/material.dart"; 4 | import "package:flutter/services.dart"; 5 | 6 | const MethodChannel _channel = MethodChannel("de.janoodle.timy/thumbnails-android"); 7 | 8 | Future getThumbnailBitmap({ 9 | @required String fileId, 10 | @required int type, 11 | }) async { 12 | final Uint8List data = await _channel.invokeMethod( 13 | "getThumbnailBitmap", 14 | { 15 | "fileId": fileId, 16 | "type": type, 17 | }, 18 | ); 19 | return data; 20 | } 21 | -------------------------------------------------------------------------------- /lib/native_channels/ios_permission_channel.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/foundation.dart"; 2 | import "package:flutter/services.dart"; 3 | 4 | enum PermissionType { Photos, Camera } 5 | 6 | class IOSPermissionChannel { 7 | static const MethodChannel channel = 8 | MethodChannel("de.janoodle.timy/permission-ios"); 9 | 10 | static Future requestPermission({ 11 | @required PermissionType permissionType 12 | }) { 13 | return channel.invokeMethod( 14 | "requestPermission", 15 | { 16 | "permissionType": _stringOf(permissionType), 17 | }, 18 | ); 19 | } 20 | 21 | static String _stringOf(PermissionType permissionType) { 22 | switch (permissionType) { 23 | case PermissionType.Camera: 24 | return "CAMERA"; 25 | case PermissionType.Photos: 26 | return "PHOTOS"; 27 | default: 28 | return ""; 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /lib/native_channels/upload_platform.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/services.dart"; 2 | import "package:meta/meta.dart"; 3 | 4 | class UploadPlatform { 5 | static const MethodChannel channel = 6 | MethodChannel("de.janoodle.timy/upload_platform"); 7 | 8 | Future uploadFiles({ 9 | @required List filePaths, // Path to files on Android and a photo just taken on iOS 10 | @required List localIdentifiers, // iOS files localIdentifiers 11 | @required String groupId, 12 | @required String channelId, 13 | }) { 14 | return channel.invokeMethod( 15 | "uploadFiles", 16 | { 17 | "filePaths": filePaths, 18 | "localIdentifiers": localIdentifiers, 19 | "groupId": groupId, 20 | "channelId": channelId, 21 | }, 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/presentation/calendar/calendar_item.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/channel.dart"; 2 | 3 | abstract class CalendarItem {} 4 | 5 | class CalendarHeaderItem implements CalendarItem { 6 | final DateTime date; 7 | final bool isToday; 8 | final bool isPast; 9 | CalendarHeaderItem({ 10 | this.date, 11 | this.isToday, 12 | this.isPast, 13 | }); 14 | } 15 | 16 | class CalendarEntryItem implements CalendarItem { 17 | final String eventId; 18 | final String groupId; 19 | final String eventName; 20 | final String groupName; 21 | final DateTime date; 22 | final RSVP rsvpStatus; 23 | final bool isSelected; 24 | final bool isAllDay; 25 | final bool isPast; 26 | CalendarEntryItem({ 27 | this.eventId, 28 | this.groupId, 29 | this.eventName, 30 | this.groupName, 31 | this.date, 32 | this.rsvpStatus, 33 | this.isSelected, 34 | this.isAllDay, 35 | this.isPast, 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /lib/presentation/channel/channel_screen_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | import "package:circles_app/domain/redux/app_selector.dart"; 3 | import "package:circles_app/domain/redux/app_state.dart"; 4 | import "package:circles_app/model/channel.dart"; 5 | import "package:circles_app/model/user.dart"; 6 | import "package:redux/redux.dart"; 7 | 8 | // ignore: prefer_double_quotes 9 | part 'channel_screen_viewmodel.g.dart'; 10 | 11 | abstract class ChannelScreenViewModel 12 | implements Built { 13 | bool get isAuthor; 14 | 15 | bool get userIsMember; 16 | 17 | String get groupId; 18 | 19 | Channel get channel; 20 | 21 | User get user; 22 | 23 | bool get failedToJoin; 24 | 25 | RSVP get rsvpStatus; 26 | 27 | ChannelScreenViewModel._(); 28 | 29 | factory ChannelScreenViewModel( 30 | [void Function(ChannelScreenViewModelBuilder) updates]) = 31 | _$ChannelScreenViewModel; 32 | 33 | static ChannelScreenViewModel fromStore(Store store) { 34 | final selectedChannel = getSelectedChannel(store.state); 35 | final hasSelectedChannel = selectedChannel != null; 36 | final channelUser = selectedChannel 37 | ?.users 38 | ?.firstWhere((u) => u.id == store.state.user.uid, orElse: () => null); 39 | 40 | return ChannelScreenViewModel((v) => v 41 | ..isAuthor = 42 | selectedChannel.authorId == store.state.user.uid 43 | ..userIsMember = hasSelectedChannel && channelUser != null 44 | ..groupId = hasSelectedChannel ? store.state.selectedGroupId : "" 45 | ..channel = selectedChannel.toBuilder() 46 | ..user = store.state.user.toBuilder() 47 | ..failedToJoin = store.state.channelState.joinChannelFailed 48 | ..rsvpStatus = channelUser?.rsvp ?? RSVP.UNSET); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/presentation/channel/details/topic_details_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | import "package:circles_app/domain/redux/app_selector.dart"; 4 | import "package:circles_app/domain/redux/app_state.dart"; 5 | import "package:circles_app/model/channel.dart"; 6 | import "package:circles_app/model/user.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | // ignore: prefer_double_quotes 10 | part 'topic_details_viewmodel.g.dart'; 11 | 12 | abstract class TopicDetailsViewModel 13 | implements Built { 14 | String get name; 15 | 16 | String get description; 17 | 18 | ChannelVisibility get visibility; 19 | 20 | BuiltList get members; 21 | 22 | String get groupId; 23 | 24 | Channel get channel; 25 | 26 | String get userId; 27 | 28 | TopicDetailsViewModel._(); 29 | 30 | factory TopicDetailsViewModel( 31 | [void Function(TopicDetailsViewModelBuilder) updates]) = 32 | _$TopicDetailsViewModel; 33 | 34 | static TopicDetailsViewModel fromStore(Store store) { 35 | final channel = getSelectedChannel(store.state); 36 | final members = store.state.groupUsers 37 | .where((user) => channel.users.any((u) => u.id == user.uid)); 38 | 39 | return TopicDetailsViewModel((t) => t 40 | ..name = channel.name 41 | ..visibility = channel.visibility 42 | ..description = channel.description ?? "" 43 | ..members.addAll(members) 44 | ..groupId = store.state.selectedGroupId 45 | ..channel = channel.toBuilder() 46 | ..userId = store.state.user.uid); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/presentation/channel/event/rsvp_dialog.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:circles_app/model/channel.dart"; 3 | import "package:circles_app/theme.dart"; 4 | import "package:flutter/material.dart"; 5 | 6 | showDialogRsvp(context, RSVP rsvp) { 7 | showDialog( 8 | context: context, 9 | builder: (context) { 10 | return Center( 11 | child: Card( 12 | child: Container( 13 | width: _Style.dialogWidth, 14 | height: _Style.dialogHeight, 15 | child: _dialogContent(context, rsvp), 16 | ), 17 | ), 18 | ); 19 | }); 20 | } 21 | 22 | String _rsvpIcon(RSVP rsvp) { 23 | switch (rsvp) { 24 | case RSVP.YES: 25 | return "assets/graphics/channel/rsvp/rsvp_yes_large.png"; 26 | break; 27 | case RSVP.MAYBE: 28 | return "assets/graphics/channel/rsvp/rsvp_maybe_large.png"; 29 | break; 30 | case RSVP.NO: 31 | return "assets/graphics/channel/rsvp/rsvp_no_large.png"; 32 | break; 33 | case RSVP.UNSET: 34 | default: 35 | return ""; 36 | break; 37 | } 38 | } 39 | 40 | Column _dialogContent(context, RSVP rsvp) { 41 | return Column( 42 | mainAxisAlignment: MainAxisAlignment.spaceEvenly, 43 | children: [ 44 | Image.asset( 45 | _rsvpIcon(rsvp), 46 | height: _Style.dialogIconSize, 47 | width: _Style.dialogIconSize, 48 | ), 49 | _rsvpText(context, rsvp), 50 | ], 51 | ); 52 | } 53 | 54 | Widget _rsvpText(context, RSVP rsvp) { 55 | switch (rsvp) { 56 | case RSVP.YES: 57 | return Text( 58 | CirclesLocalizations.of(context).eventRsvpDialogYes, 59 | style: AppTheme.dialogRsvpYesTextStyle, 60 | ); 61 | case RSVP.MAYBE: 62 | return Text( 63 | CirclesLocalizations.of(context).eventRsvpDialogMaybe, 64 | style: AppTheme.dialogRsvpMaybeTextStyle, 65 | ); 66 | case RSVP.NO: 67 | return Text( 68 | CirclesLocalizations.of(context).eventRsvpDialogNo, 69 | style: AppTheme.dialogRsvpNoTextStyle, 70 | ); 71 | case RSVP.UNSET: 72 | default: 73 | return Container(); 74 | } 75 | } 76 | 77 | class _Style { 78 | static const dialogWidth = 188.0; 79 | static const dialogHeight = 188.0; 80 | static const dialogIconSize = 64.0; 81 | } 82 | -------------------------------------------------------------------------------- /lib/presentation/channel/input/chat_input_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/ui/ui_state_selector.dart"; 4 | import "package:redux/redux.dart"; 5 | 6 | // ignore: prefer_double_quotes 7 | part 'chat_input_viewmodel.g.dart'; 8 | 9 | abstract class ChatInputViewModel 10 | implements Built { 11 | 12 | @nullable 13 | String get inputDraft; 14 | 15 | ChatInputViewModel._(); 16 | 17 | factory ChatInputViewModel( 18 | [void Function(ChatInputViewModelBuilder) updates]) = 19 | _$ChatInputViewModel; 20 | 21 | static ChatInputViewModel fromStore(Store store) { 22 | return ChatInputViewModel((v) => v 23 | ..inputDraft = getInputDraftSelectedChannel(store.state)); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/presentation/channel/input/send_button.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/message/message_actions.dart"; 4 | import "package:circles_app/presentation/channel/messages_scroll_controller.dart"; 5 | import "package:circles_app/theme.dart"; 6 | import "package:flutter/material.dart"; 7 | import "package:flutter_redux/flutter_redux.dart"; 8 | 9 | class SendButton extends StatelessWidget { 10 | const SendButton({ 11 | Key key, 12 | @required TextEditingController controller, 13 | @required bool enabled, 14 | }) : _controller = controller, 15 | _enabled = enabled, 16 | super(key: key); 17 | 18 | final TextEditingController _controller; 19 | final bool _enabled; 20 | 21 | @override 22 | Widget build(BuildContext context) { 23 | return FlatButton( 24 | child: Text( 25 | CirclesLocalizations.of(context).channelInputSend, 26 | style: AppTheme.buttonTextStyle, 27 | ), 28 | padding: EdgeInsets.all(16), 29 | disabledTextColor: AppTheme.colorTextDisabled, 30 | textColor: AppTheme.colorTextEnabled, 31 | onPressed: !_enabled 32 | ? null 33 | : () { 34 | final text = _controller.text; 35 | _controller.clear(); 36 | StoreProvider.of(context).dispatch( 37 | SendMessage(text), 38 | ); 39 | MessagesScrollController.of(context).scrollController.animateTo( 40 | 0.0, 41 | duration: Duration(milliseconds: 600), 42 | curve: Curves.fastOutSlowIn, 43 | ); 44 | }, 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/presentation/channel/invite/invite_to_channel_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | import "package:built_collection/built_collection.dart"; 4 | import "package:built_value/built_value.dart"; 5 | import "package:circles_app/domain/redux/app_state.dart"; 6 | import "package:circles_app/domain/redux/channel/channel_actions.dart"; 7 | import "package:circles_app/model/user.dart"; 8 | import "package:redux/redux.dart"; 9 | 10 | // ignore: prefer_double_quotes 11 | part 'invite_to_channel_viewmodel.g.dart'; 12 | 13 | abstract class InviteToChannelViewModel 14 | implements 15 | Built { 16 | InviteToChannelViewModel._(); 17 | 18 | BuiltList get newUsers; 19 | 20 | @BuiltValueField(compare: false) 21 | void Function(Iterable, Completer) get inviteToChannel; 22 | 23 | factory InviteToChannelViewModel( 24 | [void Function(InviteToChannelViewModelBuilder) updates]) = 25 | _$InviteToChannelViewModel; 26 | 27 | static InviteToChannelViewModel Function(Store store) fromStore( 28 | String channelId) { 29 | return (Store store) { 30 | final selectedGroup = store.state.selectedGroupId; 31 | final channel = store.state.groups[selectedGroup].channels[channelId]; 32 | 33 | // Filter out any user that is already part of the channel 34 | final newUsers = store.state.groupUsers 35 | .where((user) => !channel.users.any((u) => u.id == user.uid)); 36 | 37 | return InviteToChannelViewModel((vm) => vm 38 | ..newUsers.replace(newUsers) 39 | ..inviteToChannel = (users, completer) => 40 | store.dispatch(InviteToChannelAction(users, channel, completer))); 41 | }; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/presentation/channel/join_channel.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/channel/channel_actions.dart"; 4 | import "package:circles_app/model/channel.dart"; 5 | import "package:circles_app/model/user.dart"; 6 | import "package:flutter/cupertino.dart"; 7 | import "package:flutter/material.dart"; 8 | import "package:flutter_redux/flutter_redux.dart"; 9 | 10 | class JoinChannel extends StatelessWidget { 11 | final String _groupId; 12 | final Channel _channel; 13 | final User _user; 14 | 15 | const JoinChannel(this._groupId, this._channel, this._user); 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Padding( 20 | padding: const EdgeInsets.only(top: 30.0, bottom: 40.0), 21 | child: Container( 22 | child: Row( 23 | crossAxisAlignment: CrossAxisAlignment.start, 24 | children: [ 25 | Expanded( 26 | child: Column( 27 | children: [ 28 | Container( 29 | child: Text( 30 | CirclesLocalizations.of(context).channelJoinMessage), 31 | ), 32 | Container( 33 | child: RaisedButton( 34 | color: Colors.blue, 35 | textColor: Colors.white, 36 | child: Text(CirclesLocalizations.of(context).channelJoin), 37 | onPressed: () { 38 | StoreProvider.of(context) 39 | .dispatch(JoinChannelAction(groupId: _groupId, channel: _channel, user: _user)); 40 | }, 41 | )) 42 | ], 43 | )), 44 | ], 45 | ), 46 | )); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/presentation/channel/message/message_body.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/message.dart"; 2 | import "package:circles_app/theme.dart"; 3 | import "package:flutter/cupertino.dart"; 4 | import "package:flutter/material.dart"; 5 | import "package:flutter/widgets.dart"; 6 | import "package:flutter_linkify/flutter_linkify.dart"; 7 | import "package:linkify/linkify.dart"; 8 | import "package:url_launcher/url_launcher.dart"; 9 | 10 | class MessageBody extends StatelessWidget { 11 | const MessageBody({ 12 | Key key, 13 | @required Message message, 14 | }) : _message = message, 15 | super(key: key); 16 | 17 | final Message _message; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | if (_message.body.isEmpty) { 22 | return SizedBox.shrink(); 23 | } 24 | final elements = linkify( 25 | _message.body, 26 | humanize: true, 27 | linkTypes: null, 28 | ); 29 | return RichText( 30 | textScaleFactor: MediaQuery.of(context).textScaleFactor, 31 | text: buildTextSpan( 32 | elements, 33 | style: AppTheme.messageTextStyle, 34 | onOpen: (link) async { 35 | if (await canLaunch(link.url)) { 36 | await launch(link.url); 37 | } else { 38 | throw "Could not launch $link"; 39 | } 40 | }, 41 | linkStyle: AppTheme.linkTextStyle, 42 | ), 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/presentation/channel/message/message_timestamp.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/message.dart"; 2 | import "package:circles_app/model/user.dart"; 3 | import "package:circles_app/theme.dart"; 4 | import "package:flutter/material.dart"; 5 | import "package:intl/intl.dart"; 6 | 7 | class MessageTimestamp extends StatelessWidget { 8 | const MessageTimestamp({ 9 | Key key, 10 | @required Message message, 11 | @required User currentUser, 12 | }) : _message = message, 13 | _currentUser = currentUser, 14 | super(key: key); 15 | 16 | final Message _message; 17 | final User _currentUser; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | final timestamp = Text( 22 | DateFormat.Hm().format(_message.timestamp), 23 | style: AppTheme.messageTimestampTextStyle, 24 | ); 25 | return Padding( 26 | padding: const EdgeInsets.only(left: 8.0), 27 | child: AnimatedSwitcher( 28 | duration: Duration(milliseconds: 500), 29 | child: AnimatedSwitcher( 30 | child: _message.authorId == _currentUser.uid && _message.pending 31 | ? _buildLoading() 32 | : timestamp, 33 | duration: Duration(milliseconds: 200), 34 | ), 35 | ), 36 | ); 37 | } 38 | 39 | Widget _buildLoading() { 40 | return Icon( 41 | Icons.cached, 42 | size: 16.0, 43 | color: Colors.grey, 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/presentation/channel/message/system_message_item.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:circles_app/model/message.dart"; 3 | import "package:circles_app/theme.dart"; 4 | import "package:flutter/material.dart"; 5 | 6 | class SystemMessageItem extends StatelessWidget { 7 | final Message _message; 8 | 9 | const SystemMessageItem( 10 | this._message, { 11 | Key key, 12 | }) : super(key: key); 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | return Container( 17 | height: 48 * AppTheme.pixelMultiplier, 18 | width: MediaQuery.of(context).size.width, 19 | child: Center( 20 | child: Padding( 21 | padding: const EdgeInsets.only( 22 | left: AppTheme.appMargin, 23 | right: AppTheme.appMargin, 24 | ), 25 | // Currently only dealing with SYSTEM or RSVP messages 26 | child: Text(_message.messageType == MessageType.SYSTEM ? 27 | CirclesLocalizations.of(context).channelSystemMessage(_message.body).toUpperCase() : 28 | CirclesLocalizations.of(context).rsvpSystemMessage(_message.body).toUpperCase(), 29 | style: AppTheme.systemMessageTextStyle, 30 | textAlign: TextAlign.center, 31 | ), 32 | ), 33 | ), 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/presentation/channel/messages_list/messages_list.dart: -------------------------------------------------------------------------------- 1 | import "dart:io"; 2 | 3 | import "package:circles_app/domain/redux/app_state.dart"; 4 | import "package:circles_app/model/message.dart"; 5 | import "package:circles_app/presentation/channel/message/message_item.dart"; 6 | import "package:circles_app/presentation/channel/message/system_message_item.dart"; 7 | import "package:circles_app/presentation/channel/messages_list/messages_list_viewmodel.dart"; 8 | import "package:flutter/material.dart"; 9 | import "package:flutter_redux/flutter_redux.dart"; 10 | 11 | class MessagesList extends StatelessWidget { 12 | const MessagesList({ 13 | Key key, 14 | @required this.scrollController, 15 | }) : super(key: key); 16 | 17 | final ScrollController scrollController; 18 | 19 | @override 20 | Widget build(BuildContext context) { 21 | return GestureDetector( 22 | onTapUp: (details) { 23 | // On iOS, taping on the chat section dismisses keyboard 24 | if (Platform.isIOS) { 25 | FocusScope.of(context).requestFocus(FocusNode()); 26 | } 27 | }, 28 | child: StoreConnector( 29 | builder: (context, vm) { 30 | return ListView.builder( 31 | controller: scrollController, 32 | reverse: true, 33 | itemCount: vm.messages.length, 34 | itemBuilder: (context, index) { 35 | final message = vm.messages[index]; 36 | return _selectMessageBuilder(message, vm); 37 | }); 38 | }, 39 | converter: MessagesListViewModel.fromStore, 40 | distinct: true, 41 | ), 42 | ); 43 | } 44 | 45 | Widget _selectMessageBuilder( 46 | Message message, 47 | MessagesListViewModel vm, 48 | ) { 49 | switch (message.messageType) { 50 | case MessageType.SYSTEM: 51 | case MessageType.RSVP: 52 | return SystemMessageItem(message); 53 | break; 54 | case MessageType.USER: 55 | case MessageType.MEDIA: 56 | return MessageItem( 57 | message: message, 58 | currentUser: vm.currentUser, 59 | userIsMember: vm.userIsMember, 60 | author: vm.authors[message.authorId], 61 | ); 62 | break; 63 | default: 64 | return SizedBox.shrink(); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/presentation/channel/messages_list/messages_list_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | import "package:circles_app/domain/redux/app_selector.dart"; 4 | import "package:circles_app/domain/redux/app_state.dart"; 5 | import "package:circles_app/model/message.dart"; 6 | import "package:circles_app/model/user.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | // ignore: prefer_double_quotes 10 | part 'messages_list_viewmodel.g.dart'; 11 | 12 | abstract class MessagesListViewModel 13 | implements Built { 14 | @nullable 15 | User get currentUser; 16 | 17 | BuiltList get messages; 18 | 19 | bool get userIsMember; 20 | 21 | BuiltMap get authors; 22 | 23 | MessagesListViewModel._(); 24 | 25 | factory MessagesListViewModel( 26 | [void Function(MessagesListViewModelBuilder) updates]) = 27 | _$MessagesListViewModel; 28 | 29 | static MessagesListViewModel fromStore(Store store) { 30 | return MessagesListViewModel((m) => m 31 | ..messages = store.state.messagesOnScreen.toBuilder() 32 | ..currentUser = store.state.user?.toBuilder() 33 | ..authors = MapBuilder( 34 | store.state.groupUsers.asMap().map((k, v) => MapEntry(v.uid, v))) 35 | ..userIsMember = getSelectedChannel(store.state)?.users 36 | ?.any((u) => u.id == store.state.user.uid)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/presentation/channel/messages_scroll_controller.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/material.dart"; 2 | 3 | class MessagesScrollController extends InheritedWidget { 4 | final ScrollController scrollController; 5 | 6 | const MessagesScrollController({ 7 | Key key, 8 | @required Widget child, 9 | @required this.scrollController, 10 | }) : super(key: key, child: child); 11 | 12 | @override 13 | bool updateShouldNotify(InheritedWidget oldWidget) { 14 | return true; 15 | } 16 | 17 | static MessagesScrollController of(BuildContext context) => 18 | context.inheritFromWidgetOfExactType(MessagesScrollController); 19 | } 20 | -------------------------------------------------------------------------------- /lib/presentation/channel/reaction/emoji_picker.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/domain/redux/message/message_actions.dart"; 3 | import "package:circles_app/model/message.dart"; 4 | import "package:circles_app/theme.dart"; 5 | import "package:flutter/cupertino.dart"; 6 | import "package:flutter/material.dart"; 7 | import "package:flutter_redux/flutter_redux.dart"; 8 | 9 | final emojiPickerOptions = const [ 10 | "❤️", 11 | "😂", 12 | "🔥", 13 | "😍", 14 | "👍", 15 | "🤔", 16 | "👽", 17 | "😊", 18 | "🥰", 19 | ]; 20 | 21 | void showEmojiPicker(BuildContext context, Message message) { 22 | showCupertinoModalPopup( 23 | context: context, 24 | builder: (context) { 25 | return EmojiPicker(message); 26 | }); 27 | } 28 | 29 | class EmojiPicker extends StatelessWidget { 30 | final Message _message; 31 | 32 | const EmojiPicker(this._message); 33 | 34 | @override 35 | Widget build(BuildContext context) { 36 | return SafeArea( 37 | child: Padding( 38 | padding: const EdgeInsets.all(16.0), 39 | child: Container( 40 | decoration: BoxDecoration( 41 | borderRadius: BorderRadius.all(Radius.circular(16)), 42 | color: Colors.white, 43 | ), 44 | child: Padding( 45 | padding: const EdgeInsets.all(8.0), 46 | child: Wrap( 47 | children: emojiPickerOptions.map((emoji) => 48 | Material( 49 | color: Colors.white, 50 | child: InkWell( 51 | child: Padding( 52 | padding: const EdgeInsets.all(8.0), 53 | child: Text( 54 | emoji, 55 | style: TextStyle(fontSize: 20 * AppTheme.pixelMultiplier), 56 | ), 57 | ), 58 | onTap: () { 59 | StoreProvider.of(context).dispatch(EmojiReaction( 60 | _message.id, 61 | emoji, 62 | )); 63 | Navigator.of(context).pop(); 64 | }, 65 | ), 66 | ) 67 | ).toList() 68 | ), 69 | ), 70 | ), 71 | ), 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/presentation/channel/reaction/reaction_button.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/message.dart"; 2 | import "package:circles_app/presentation/channel/reaction/emoji_picker.dart"; 3 | import "package:circles_app/presentation/channel/reaction/reaction.dart"; 4 | import "package:circles_app/theme.dart"; 5 | import "package:flutter/cupertino.dart"; 6 | import "package:flutter/material.dart"; 7 | 8 | class ReactionButton extends StatelessWidget { 9 | const ReactionButton( 10 | this._message, 11 | ); 12 | 13 | final Message _message; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return InkWell( 18 | child: emojiBorder( 19 | Image.asset( 20 | "assets/graphics/icon_smile.png", 21 | height: 16 * AppTheme.pixelMultiplier, 22 | width: 16 * AppTheme.pixelMultiplier, 23 | ), 24 | ), 25 | onTap: () { 26 | showEmojiPicker(context, _message); 27 | }, 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/presentation/channel/reaction/reaction_detail_data.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | 3 | // ignore: prefer_double_quotes 4 | part 'reaction_detail_data.g.dart'; 5 | 6 | abstract class ReactionDetailData 7 | implements Built { 8 | String get emoji; 9 | 10 | String get names; 11 | 12 | ReactionDetailData._(); 13 | 14 | factory ReactionDetailData( 15 | [void Function(ReactionDetailDataBuilder) updates]) = 16 | _$ReactionDetailData; 17 | } 18 | -------------------------------------------------------------------------------- /lib/presentation/channel/reaction/reaction_section.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/message.dart"; 2 | import "package:circles_app/model/user.dart"; 3 | import "package:circles_app/presentation/channel/reaction/reaction.dart"; 4 | import "package:circles_app/presentation/channel/reaction/reaction_button.dart"; 5 | import "package:circles_app/routes.dart"; 6 | import "package:flutter/material.dart"; 7 | 8 | class ReactionSection extends StatelessWidget { 9 | const ReactionSection({ 10 | Key key, 11 | @required Message message, 12 | @required User currentUser, 13 | @required bool userIsMember, 14 | }) : _message = message, 15 | _currentUser = currentUser, 16 | _userIsMember = userIsMember, 17 | super(key: key); 18 | 19 | final Message _message; 20 | final User _currentUser; 21 | final bool _userIsMember; 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | final userEmoji = _message.reactions[_currentUser.uid]; 26 | 27 | final list = []; 28 | 29 | _message.reactionsCount().forEach((emoji, count) { 30 | final isUserEmoji = userEmoji?.emoji == emoji; 31 | list.add(Reaction( 32 | emoji: emoji, 33 | count: count, 34 | isUserEmoji: isUserEmoji, 35 | messageId: _message.id, 36 | )); 37 | }); 38 | 39 | if (list.isNotEmpty && 40 | _currentUser.uid != _message.authorId && 41 | !_message.reactions.containsKey(_currentUser.uid) && 42 | _userIsMember) { 43 | list.add(ReactionButton(_message)); 44 | } 45 | 46 | return Padding( 47 | padding: const EdgeInsets.only(top: 8.0), 48 | child: InkWell( 49 | onLongPress: () { 50 | Navigator.of(context).pushNamed( 51 | Routes.reaction, 52 | arguments: _message.reactions, 53 | ); 54 | }, 55 | // Wrap takes care of showing the each reaction one after the other 56 | // and when it runs out of space, will go to the next line. 57 | child: Wrap( 58 | spacing: 8.0, 59 | runSpacing: 8.0, 60 | direction: Axis.horizontal, 61 | children: list, 62 | ), 63 | ), 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /lib/presentation/common/color_label_text_form_field.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/theme.dart"; 2 | import "package:flutter/material.dart"; 3 | 4 | /// 5 | /// This is a TextFormField which will change the Label color 6 | /// when it is not empty. 7 | /// 8 | /// i.e. When the content is not empty, the whole TextFormField 9 | /// becomes the normal color: the border and the label. 10 | /// 11 | /// If you are also using validation, use ErrorLabelTextFormField 12 | /// 13 | class ColorLabelTextFormField extends StatefulWidget { 14 | const ColorLabelTextFormField({ 15 | Key key, 16 | String labelText, 17 | String helperText, 18 | @required TextEditingController controller, 19 | }) : _controller = controller, 20 | _labelText = labelText, 21 | _helperText = helperText, 22 | super(key: key); 23 | 24 | final TextEditingController _controller; 25 | final String _labelText; 26 | final String _helperText; 27 | 28 | @override 29 | _ColorLabelTextFormFieldState createState() => 30 | _ColorLabelTextFormFieldState(); 31 | } 32 | 33 | class _ColorLabelTextFormFieldState extends State { 34 | bool _isEmpty = true; 35 | 36 | @override 37 | void initState() { 38 | super.initState(); 39 | widget._controller.addListener(() { 40 | setState(() { 41 | _isEmpty = widget._controller.text.isEmpty; 42 | }); 43 | }); 44 | } 45 | 46 | @override 47 | Widget build(BuildContext context) { 48 | final theme = _isEmpty 49 | ? AppTheme.inputDecorationEmptyTheme 50 | : AppTheme.inputDecorationFilledTheme; 51 | 52 | return TextFormField( 53 | style: AppTheme.inputMediumTextStyle, 54 | decoration: InputDecoration( 55 | labelText: widget._labelText, 56 | helperText: widget._helperText, 57 | ).applyDefaults(theme), 58 | controller: widget._controller, 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/presentation/common/modal_item.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/theme.dart"; 2 | import "package:flutter/cupertino.dart"; 3 | 4 | class ModalItem extends StatelessWidget { 5 | const ModalItem({ 6 | Key key, 7 | this.iconAsset, 8 | this.iconData, 9 | @required this.label, 10 | }) : super(key: key); 11 | 12 | final String iconAsset; 13 | final String label; 14 | final IconData iconData; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Row( 19 | children: [ 20 | Padding( 21 | padding: const EdgeInsets.only( 22 | left: 8.0, 23 | right: 8.0, 24 | ), 25 | child: iconAsset != null 26 | ? Image.asset( 27 | iconAsset, 28 | scale: 3, 29 | ) 30 | : Icon(iconData), 31 | ), 32 | Text( 33 | label, 34 | style: AppTheme.optionTextStyle, 35 | textScaleFactor: 1, 36 | ), 37 | ], 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/presentation/common/platform_alerts.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:flutter/widgets.dart"; 3 | import "package:flutter_platform_widgets/flutter_platform_widgets.dart"; 4 | 5 | enum AccessResourceType { CAMERA, STORAGE } 6 | 7 | showNoAccessAlert({ 8 | AccessResourceType type, 9 | BuildContext context, 10 | }) { 11 | final dialog = PlatformAlertDialog( 12 | title: Text(CirclesLocalizations.of(context).platformAlertAccessTitle), 13 | content: 14 | Text(CirclesLocalizations.of(context).platformAlertAccessBody(type)), 15 | actions: [ 16 | PlatformDialogAction( 17 | child: PlatformText(CirclesLocalizations.of(context).ok), 18 | onPressed: () { 19 | Navigator.pop(context); 20 | }), 21 | ], 22 | ); 23 | 24 | return showPlatformDialog( 25 | context: context, 26 | builder: (_) => dialog, 27 | ); 28 | } 29 | 30 | /// Present PlatformDialog for fetures yet to implement. 31 | showSoonAlert({BuildContext context}) { 32 | final actions = [ 33 | PlatformDialogAction( 34 | child: PlatformText(CirclesLocalizations.of(context).cancel), 35 | onPressed: () { 36 | Navigator.pop(context); 37 | }, 38 | ), 39 | ]; 40 | 41 | return showPlatformDialog( 42 | context: context, 43 | builder: (_) => PlatformAlertDialog( 44 | title: Text(CirclesLocalizations.of(context).genericSoonAlertTitle), 45 | content: Text(CirclesLocalizations.of(context).genericSoonAlertMessage), 46 | actions: actions, 47 | ), 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /lib/presentation/common/round_button.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/theme.dart"; 2 | import "package:flutter/material.dart"; 3 | import "package:flutter/rendering.dart"; 4 | 5 | class RoundButton extends StatelessWidget { 6 | const RoundButton({ 7 | @required this.text, 8 | @required this.onTap, 9 | }); 10 | 11 | final String text; 12 | final Function onTap; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | final borderRadius = BorderRadius.circular(24); 17 | return Material( 18 | color: Colors.transparent, 19 | borderRadius: borderRadius, 20 | child: InkWell( 21 | onTap: onTap, 22 | borderRadius: borderRadius, 23 | child: Container( 24 | decoration: BoxDecoration( 25 | borderRadius: borderRadius, 26 | border: Border.all( 27 | color: AppTheme.colorDarkBlue 28 | ) 29 | ), 30 | height: 40, 31 | width: 200, 32 | child: Center( 33 | child: Text( 34 | text, 35 | style: AppTheme.buttonTextStyle.apply( 36 | color: AppTheme.colorDarkBlue 37 | ), 38 | ), 39 | ), 40 | ), 41 | ), 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/presentation/home/channel_list/group_status_icon_widget.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/theme.dart"; 2 | import "package:flutter/widgets.dart"; 3 | 4 | class GroupStatusIconWidget extends StatelessWidget { 5 | final bool _joined; 6 | final bool _isPrivateChannel; 7 | 8 | const GroupStatusIconWidget({joined, isPrivateChannel, Key key}) 9 | : _joined = joined, 10 | _isPrivateChannel = isPrivateChannel, 11 | super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return Container( 16 | child: Container( 17 | width: _Style.width, 18 | height: _Style.width, 19 | child: Padding( 20 | padding: EdgeInsets.only(top: 5.0), 21 | child: Stack( 22 | children: [ 23 | Image.asset( 24 | _joined 25 | ? "assets/graphics/channel/topic_joined.png" 26 | : "assets/graphics/channel/topic_open.png", 27 | width: _Style.imageSize.width, 28 | color: AppTheme.colorDarkBlue, 29 | ), 30 | Visibility( 31 | visible: _isPrivateChannel, 32 | child: Image.asset( 33 | "assets/graphics/channel/padlock.png", 34 | width: _Style.imageSize.width, 35 | height: _Style.imageSize.height, 36 | ), 37 | ), 38 | ], 39 | )))); 40 | } 41 | } 42 | 43 | class _Style { 44 | static const imageSize = Size(25, 26); 45 | static const width = 32.0; 46 | } 47 | -------------------------------------------------------------------------------- /lib/presentation/home/circles_drawer.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/presentation/calendar/calendar_screen.dart"; 2 | import "package:circles_app/presentation/home/channel_list/channel_list.dart"; 3 | import "package:circles_app/presentation/home/group_list/group_list.dart"; 4 | import "package:circles_app/theme.dart"; 5 | import "package:flutter/material.dart"; 6 | 7 | enum DrawerState { CALENDAR, CHANNEL } 8 | 9 | class CirclesDrawer extends StatefulWidget { 10 | @override 11 | _CirclesDrawerState createState() => _CirclesDrawerState(); 12 | } 13 | 14 | class _CirclesDrawerState extends State { 15 | DrawerState _drawerState = DrawerState.CHANNEL; 16 | 17 | _drawerStateChange(DrawerState state) { 18 | setState(() { 19 | _drawerState = state; 20 | }); 21 | } 22 | 23 | @override 24 | Widget build(BuildContext context) { 25 | return Drawer( 26 | child: Container( 27 | decoration: BoxDecoration( 28 | color: AppTheme.colorMintGreen, 29 | image: DecorationImage( 30 | colorFilter: ColorFilter.mode( 31 | Color.fromRGBO(255, 255, 255, 0.1), 32 | BlendMode.modulate, 33 | ), 34 | image: AssetImage("assets/graphics/visual_twist_white_petrol.png"), 35 | fit: BoxFit.cover, 36 | ), 37 | ), 38 | child: Row( 39 | crossAxisAlignment: CrossAxisAlignment.start, 40 | children: [ 41 | GroupList(_drawerStateChange), 42 | _drawerState == DrawerState.CALENDAR 43 | ? CalendarScreen() 44 | : ChannelsList() 45 | ], 46 | ), 47 | )); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/presentation/home/group_list/group_list_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:built_value/built_value.dart"; 3 | import "package:circles_app/domain/redux/app_state.dart"; 4 | import "package:circles_app/model/group.dart"; 5 | import "package:redux/redux.dart"; 6 | 7 | // ignore: prefer_double_quotes 8 | part 'group_list_viewmodel.g.dart'; 9 | 10 | abstract class GroupListViewModel 11 | implements Built { 12 | BuiltList get groups; 13 | BuiltList get updatedGroups; 14 | String get selectedGroupId; 15 | 16 | GroupListViewModel._(); 17 | 18 | factory GroupListViewModel( 19 | [void Function(GroupListViewModelBuilder) updates]) = 20 | _$GroupListViewModel; 21 | 22 | static GroupListViewModel fromStore(Store store) { 23 | final unreadGroupsMap = store.state.user.unreadUpdates.toMap(); 24 | unreadGroupsMap.removeWhere((key, value) => value == null || value.length == 0); 25 | 26 | return GroupListViewModel((c) => c 27 | ..groups = ListBuilder(store.state.groups.values) 28 | ..selectedGroupId = store.state.selectedGroupId 29 | ..updatedGroups = ListBuilder(unreadGroupsMap.keys)); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/presentation/home/home_app_bar_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | import "package:circles_app/circles_localization.dart"; 3 | import "package:circles_app/domain/redux/app_selector.dart"; 4 | import "package:circles_app/domain/redux/app_state.dart"; 5 | import "package:circles_app/model/channel.dart"; 6 | import "package:circles_app/util/date_formatting.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | // ignore: prefer_double_quotes 10 | part 'home_app_bar_viewmodel.g.dart'; 11 | 12 | abstract class HomeAppBarViewModel 13 | implements Built { 14 | HomeAppBarViewModel._(); 15 | 16 | factory HomeAppBarViewModel( 17 | [void Function(HomeAppBarViewModelBuilder) updates]) = 18 | _$HomeAppBarViewModel; 19 | 20 | bool get hasUpdatedChannelsInGroup; 21 | 22 | String get title; 23 | 24 | bool get memberOfChannel; 25 | 26 | bool get isEvent; 27 | 28 | String get eventDate; 29 | 30 | static Function(Store) fromStore(context) { 31 | return (Store store) { 32 | final channel = getSelectedChannel(store.state); 33 | final groupId = store.state.selectedGroupId; 34 | final channels = store.state.groups[groupId].channels.values.toList(); 35 | final hasGroupUpdates = 36 | channels.any((c) => (c != channel) && c.hasUpdates); 37 | 38 | final isMemberOfChannel = 39 | channel.users.any((u) => u.id == store.state.user.uid); 40 | 41 | return HomeAppBarViewModel((vm) { 42 | return vm 43 | ..title = channel.name 44 | ..memberOfChannel = isMemberOfChannel 45 | ..hasUpdatedChannelsInGroup = hasGroupUpdates 46 | ..isEvent = channel.type == ChannelType.EVENT 47 | ..eventDate = _formatDate(context, channel); 48 | }); 49 | }; 50 | } 51 | 52 | static String _formatDate(context, Channel channel) { 53 | if (channel.startDate == null) { 54 | return ""; 55 | } 56 | try { 57 | if (channel.hasStartTime) { 58 | return "${formatDate(context, channel.startDate)} " 59 | "${CirclesLocalizations.of(context).at} " 60 | "${formatTime(context, channel.startDate)}"; 61 | } else { 62 | return formatDate(context, channel.startDate); 63 | } 64 | } catch (error) { 65 | return ""; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/presentation/home/homescreen.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/presentation/channel/channel_screen.dart"; 3 | import "package:circles_app/presentation/home/circles_drawer.dart"; 4 | import "package:circles_app/presentation/home/home_app_bar.dart"; 5 | import "package:circles_app/presentation/home/in_app_notification/in_app_notification_viewmodel.dart"; 6 | import "package:circles_app/presentation/home/in_app_notification/in_app_notification_widget.dart"; 7 | import "package:flutter/material.dart"; 8 | import "package:flutter_redux/flutter_redux.dart"; 9 | 10 | class HomeScreen extends StatefulWidget { 11 | final ValueNotifier sideOpenController; 12 | 13 | const HomeScreen({ 14 | Key key, 15 | @required this.sideOpenController, 16 | }) : super(key: key); 17 | 18 | @override 19 | _HomeScreenState createState() => _HomeScreenState(); 20 | } 21 | 22 | class _HomeScreenState extends State { 23 | final _scaffoldKey = GlobalKey(); 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return Stack( 28 | children: [ 29 | Scaffold( 30 | key: _scaffoldKey, 31 | appBar: HomeAppBar( 32 | scaffoldKey: _scaffoldKey, 33 | sideOpenController: widget.sideOpenController, 34 | ), 35 | body: ChannelScreen(), 36 | drawer: CirclesDrawer(), 37 | ), 38 | StoreConnector( 39 | builder: (BuildContext context, InAppNotificationViewModel vm) { 40 | return vm.inAppNotification != null 41 | ? InAppNotificationWidget(vm) 42 | : Container(); 43 | }, 44 | converter: InAppNotificationViewModel.fromStore, 45 | distinct: true, 46 | ), 47 | ], 48 | ); 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /lib/presentation/home/in_app_notification/in_app_notification_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | import "package:circles_app/domain/redux/app_actions.dart"; 3 | import "package:circles_app/domain/redux/app_state.dart"; 4 | import "package:circles_app/domain/redux/channel/channel_actions.dart"; 5 | import "package:circles_app/domain/redux/push/push_actions.dart"; 6 | import "package:circles_app/model/in_app_notification.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | // ignore: prefer_double_quotes 10 | part 'in_app_notification_viewmodel.g.dart'; 11 | 12 | abstract class InAppNotificationViewModel 13 | implements 14 | Built { 15 | @nullable 16 | InAppNotification get inAppNotification; 17 | 18 | @BuiltValueField(compare: false) 19 | Function get onDismissed; 20 | 21 | @BuiltValueField(compare: false) 22 | Function get onTap; 23 | 24 | InAppNotificationViewModel._(); 25 | 26 | factory InAppNotificationViewModel( 27 | [void Function(InAppNotificationViewModelBuilder) updates]) = 28 | _$InAppNotificationViewModel; 29 | 30 | static InAppNotificationViewModel fromStore(Store store) { 31 | final previouslySelectedChannel = store.state.channelState.selectedChannel; 32 | return InAppNotificationViewModel((i) => i 33 | ..inAppNotification = store.state.inAppNotification?.toBuilder() 34 | ..onDismissed = () { 35 | store.dispatch(OnPushNotificationDismissedAction()); 36 | } 37 | ..onTap = () { 38 | store.dispatch(SelectGroup(store.state.inAppNotification.groupId)); 39 | store.dispatch(SelectChannel( 40 | previousChannelId: previouslySelectedChannel, 41 | channel: store.state.inAppNotification.channel, 42 | groupId: store.state.inAppNotification.groupId, 43 | userId: store.state.user.uid, 44 | )); 45 | store.dispatch(OnPushNotificationDismissedAction()); 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/presentation/home/main_screen.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/model/channel.dart"; 3 | import "package:circles_app/presentation/channel/details/topic_details.dart"; 4 | import "package:circles_app/presentation/channel/event/event_details.dart"; 5 | import "package:circles_app/presentation/home/homescreen.dart"; 6 | import "package:circles_app/presentation/home/main_screen_viewmodel.dart"; 7 | import "package:circles_app/presentation/home/slide_out_screen.dart"; 8 | import "package:flutter/material.dart"; 9 | import "package:flutter_redux/flutter_redux.dart"; 10 | 11 | /// 12 | /// This screen loads the HomeScreen when there's data loaded 13 | /// to avoid things like having null User, null Channel, etc. 14 | /// 15 | /// Also holds the ValueNotifier for the side open/closed state 16 | /// as it passes it to the SlideOut widget and the HomeScreen. 17 | /// 18 | class MainScreen extends StatefulWidget { 19 | @override 20 | _MainScreenState createState() => _MainScreenState(); 21 | } 22 | 23 | class _MainScreenState extends State { 24 | ValueNotifier _sideOpenController; 25 | 26 | @override 27 | void initState() { 28 | super.initState(); 29 | _sideOpenController = ValueNotifier(false); 30 | } 31 | 32 | @override 33 | void dispose() { 34 | super.dispose(); 35 | _sideOpenController.dispose(); 36 | } 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | return StoreConnector( 41 | distinct: true, 42 | converter: MainScreenViewModel.fromStore, 43 | builder: (context, vm) { 44 | if (vm.hasData) { 45 | return SlideOutScreen( 46 | main: HomeScreen( 47 | sideOpenController: _sideOpenController, 48 | ), 49 | side: _buildDetails(vm), 50 | sideOpenController: _sideOpenController, 51 | ); 52 | } else { 53 | // TODO: Proper empty state screen 54 | return Scaffold(); 55 | } 56 | }, 57 | ); 58 | } 59 | 60 | Widget _buildDetails(MainScreenViewModel vm) { 61 | switch (vm.channelType) { 62 | case ChannelType.TOPIC: 63 | return TopicDetails( 64 | sideOpenController: _sideOpenController, 65 | ); 66 | case ChannelType.EVENT: 67 | return EventDetails( 68 | sideOpenController: _sideOpenController, 69 | ); 70 | } 71 | return null; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /lib/presentation/home/main_screen_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "package:built_value/built_value.dart"; 2 | import "package:circles_app/domain/redux/app_selector.dart"; 3 | import "package:circles_app/domain/redux/app_state.dart"; 4 | import "package:circles_app/model/channel.dart"; 5 | import "package:redux/redux.dart"; 6 | 7 | // ignore: prefer_double_quotes 8 | part 'main_screen_viewmodel.g.dart'; 9 | 10 | abstract class MainScreenViewModel 11 | implements Built { 12 | bool get hasData; 13 | 14 | @nullable 15 | ChannelType get channelType; 16 | 17 | MainScreenViewModel._(); 18 | 19 | factory MainScreenViewModel( 20 | [void Function(MainScreenViewModelBuilder) updates]) = 21 | _$MainScreenViewModel; 22 | 23 | static bool _hasData(Store store) { 24 | return store.state.user != null && 25 | store.state.channelState.selectedChannel != null; 26 | } 27 | 28 | static MainScreenViewModel fromStore(Store store) { 29 | return MainScreenViewModel((vm) => vm 30 | ..hasData = _hasData(store) 31 | ..channelType = getSelectedChannel(store.state)?.type); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /lib/presentation/image/image_pinch_screen.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/material.dart"; 2 | import "package:pinch_zoom_image/pinch_zoom_image.dart"; 3 | import "package:transparent_image/transparent_image.dart"; 4 | 5 | class ImagePinchScreen extends StatelessWidget { 6 | 7 | @override 8 | Widget build(BuildContext context) { 9 | final String url = ModalRoute.of(context).settings.arguments; 10 | return SafeArea( 11 | child: GestureDetector( 12 | child: PinchZoomImage( 13 | image: FadeInImage.memoryNetwork( 14 | image: url, 15 | placeholder: kTransparentImage, 16 | ), 17 | ), 18 | onTap: () { 19 | Navigator.pop(context); 20 | }, 21 | ), 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /lib/presentation/image/image_with_loader.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/theme.dart"; 2 | import "package:flutter/material.dart"; 3 | import "package:transparent_image/transparent_image.dart"; 4 | 5 | class ImageWithLoader extends StatelessWidget { 6 | const ImageWithLoader({ 7 | this.url, 8 | this.fit = BoxFit.cover, 9 | this.loaderSize = 48.0, 10 | }); 11 | 12 | final String url; 13 | final BoxFit fit; 14 | final double loaderSize; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Stack( 19 | fit: StackFit.expand, 20 | alignment: Alignment.center, 21 | children: [ 22 | Container( 23 | color: AppTheme.colorGrey241, 24 | child: Center( 25 | child: SizedBox( 26 | width: loaderSize, 27 | height: loaderSize, 28 | child: _buildCircularProgressIndicator(), 29 | ), 30 | ), 31 | ), 32 | FadeInImage.memoryNetwork( 33 | image: url, 34 | fit: fit, 35 | placeholder: kTransparentImage, 36 | ), 37 | ], 38 | ); 39 | } 40 | } 41 | 42 | CircularProgressIndicator _buildCircularProgressIndicator() { 43 | return CircularProgressIndicator( 44 | backgroundColor: AppTheme.colorGrey225, 45 | strokeWidth: 3, 46 | valueColor: AlwaysStoppedAnimation(AppTheme.colorGrey155), 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /lib/presentation/login/auth_button.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/material.dart"; 2 | import "package:flutter/widgets.dart"; 3 | 4 | class AuthButton extends StatelessWidget { 5 | final String buttonText; 6 | final Function onPressedCallback; 7 | 8 | const AuthButton({ 9 | @required this.buttonText, 10 | this.onPressedCallback 11 | }); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | return RaisedButton( 16 | onPressed: onPressedCallback, 17 | color: Colors.blue, 18 | child: Container( 19 | height: 50.0, 20 | alignment: Alignment.center, 21 | child: Text( 22 | buttonText, 23 | textAlign: TextAlign.center, 24 | style: TextStyle(color: Colors.white) 25 | ) 26 | ), 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/presentation/login/auth_button_container.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_state.dart"; 2 | import "package:circles_app/circles_localization.dart"; 3 | import "package:circles_app/domain/redux/authentication/auth_actions.dart"; 4 | import "package:circles_app/presentation/login/auth_button.dart"; 5 | import "package:flutter/cupertino.dart"; 6 | import "package:flutter_redux/flutter_redux.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | class AuthButtonContainer extends StatelessWidget { 10 | const AuthButtonContainer(); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | return StoreConnector( 15 | converter: _ViewModel.fromStore, 16 | builder: (BuildContext context, _ViewModel viewModel) { 17 | return AuthButton( 18 | buttonText: viewModel.isLoggedIn ? CirclesLocalizations.of(context).logOut : CirclesLocalizations.of(context).logIn, 19 | onPressedCallback: viewModel.onPressedCallback 20 | ); 21 | } 22 | ); 23 | } 24 | } 25 | 26 | class _ViewModel { 27 | final bool isLoggedIn; 28 | final Function onPressedCallback; 29 | 30 | _ViewModel(this.isLoggedIn, this.onPressedCallback); 31 | 32 | static _ViewModel fromStore(Store store) { 33 | return _ViewModel( 34 | store.state.user != null, 35 | () { 36 | if (store.state.user != null) { 37 | store.dispatch(LogOutAction()); 38 | } else { 39 | store.dispatch(LogIn()); 40 | } 41 | } 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/presentation/settings/privacy_settings_button.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:flutter/material.dart"; 3 | import "package:url_launcher/url_launcher.dart"; 4 | 5 | class PrivacySettingsButton extends StatelessWidget { 6 | const PrivacySettingsButton({ 7 | Key key, 8 | }) : super(key: key); 9 | 10 | @override 11 | Widget build(BuildContext context) { 12 | return FlatButton( 13 | child: Text(CirclesLocalizations.of(context).privacyButton), 14 | onPressed: () { 15 | launch(CirclesLocalizations.of(context).privacyLink); 16 | }, 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/presentation/settings/settings_screen.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_localization.dart"; 2 | import "package:circles_app/presentation/common/common_app_bar.dart"; 3 | import "package:circles_app/presentation/settings/privacy_settings_button.dart"; 4 | import "package:flutter/material.dart"; 5 | 6 | class SettingsScreen extends StatelessWidget { 7 | @override 8 | Widget build(BuildContext context) { 9 | return Scaffold( 10 | appBar: CommonAppBar( 11 | title: CirclesLocalizations.of(context).settingsTitle, 12 | ), 13 | body: ListView( 14 | children: [ 15 | PrivacySettingsButton(), 16 | ], 17 | ), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /lib/presentation/user/profile_avatar.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/user.dart"; 2 | import "package:circles_app/presentation/user/user_avatar.dart"; 3 | import "package:flutter/material.dart"; 4 | 5 | class ProfileAvatar extends StatelessWidget { 6 | const ProfileAvatar({ 7 | Key key, 8 | this.pictureIconButton, 9 | @required this.user, 10 | }) : super(key: key); 11 | 12 | final Widget pictureIconButton; 13 | final User user; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Stack( 18 | alignment: Alignment.center, 19 | children: [ 20 | SizedBox( 21 | width: _Style.avatarSize, 22 | child: Stack( 23 | alignment: Alignment.center, 24 | children: [ 25 | CircularProgressIndicator(), 26 | UserAvatar( 27 | user: user, 28 | size: _Style.avatarSize, 29 | ), 30 | Positioned( 31 | bottom: 12, 32 | right: 12, 33 | child: AnimatedSwitcher( 34 | child: pictureIconButton ?? SizedBox.shrink(), 35 | duration: Duration(milliseconds: 200), 36 | ), 37 | ) 38 | ], 39 | ), 40 | ), 41 | ], 42 | ); 43 | } 44 | } 45 | 46 | class _Style { 47 | static const double avatarSize = 200.0; 48 | } 49 | -------------------------------------------------------------------------------- /lib/presentation/user/rsvp_icon.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/channel.dart"; 2 | import "package:flutter/material.dart"; 3 | import "package:flutter/widgets.dart"; 4 | 5 | class RsvpIcon extends StatelessWidget { 6 | final RSVP rsvp; 7 | 8 | const RsvpIcon({ 9 | @required this.rsvp, 10 | }); 11 | 12 | @override 13 | Widget build(BuildContext context) { 14 | final height = 24.0, width = 24.0; 15 | switch (rsvp) { 16 | case RSVP.YES: 17 | return Image.asset( 18 | "assets/graphics/channel/rsvp/rsvp_yes.png", 19 | height: height, 20 | width: width, 21 | ); 22 | case RSVP.MAYBE: 23 | return Image.asset( 24 | "assets/graphics/channel/rsvp/rsvp_maybe.png", 25 | height: height, 26 | width: width, 27 | ); 28 | case RSVP.NO: 29 | case RSVP.UNSET: 30 | default: 31 | return Container(); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/presentation/user/selected_item.dart: -------------------------------------------------------------------------------- 1 | import "package:flutter/material.dart"; 2 | import "package:flutter/widgets.dart"; 3 | 4 | class SelectedItem extends StatelessWidget { 5 | final bool selected; 6 | 7 | const SelectedItem({ 8 | this.selected, 9 | }); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | final height = 24.0, width = 24.0; 14 | return (selected 15 | ? Image.asset( 16 | "assets/graphics/input/checkbox_active.png", 17 | height: height, 18 | width: width, 19 | ) 20 | : Image.asset( 21 | "assets/graphics/input/checkbox_inactive.png", 22 | height: height, 23 | width: width, 24 | )); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lib/presentation/user/user_avatar.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/model/user.dart"; 2 | import "package:circles_app/theme.dart"; 3 | import "package:flutter/material.dart"; 4 | import "package:transparent_image/transparent_image.dart"; 5 | 6 | class UserAvatar extends StatelessWidget { 7 | const UserAvatar({ 8 | @required this.user, 9 | this.size = AppTheme.avatarSize, 10 | }); 11 | 12 | // user can be null 13 | final User user; 14 | final double size; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | if (user?.image == null) { 19 | return Image.asset( 20 | "assets/graphics/avatar_no_picture.png", 21 | height: size, 22 | width: size, 23 | fit: BoxFit.contain, 24 | ); 25 | } 26 | 27 | return ClipRRect( 28 | borderRadius: BorderRadius.circular(8.0), 29 | child: FadeInImage.memoryNetwork( 30 | image: user.image, 31 | height: size, 32 | width: size, 33 | fit: BoxFit.fitHeight, 34 | placeholder: kTransparentImage, 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /lib/presentation/user/user_screen_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | import "package:built_value/built_value.dart"; 4 | import "package:circles_app/domain/redux/app_state.dart"; 5 | import "package:circles_app/domain/redux/user/user_actions.dart"; 6 | import "package:circles_app/model/user.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | // ignore: prefer_double_quotes 10 | part 'user_screen_viewmodel.g.dart'; 11 | 12 | abstract class UserScreenViewModel 13 | implements Built { 14 | User get user; 15 | 16 | bool get isYou; 17 | 18 | @BuiltValueField(compare: false) 19 | void Function(User user, Completer completer) get submit; 20 | 21 | UserScreenViewModel._(); 22 | 23 | factory UserScreenViewModel( 24 | [void Function(UserScreenViewModelBuilder) updates]) = 25 | _$UserScreenViewModel; 26 | 27 | static fromStore(String userId) { 28 | return (Store store) { 29 | return UserScreenViewModel((u) => u 30 | ..user = _getUser(store, userId) 31 | ..isYou = userId == store.state.user.uid 32 | ..submit = (user, completer) => 33 | store.dispatch(UpdateUserAction(user, completer))); 34 | }; 35 | } 36 | 37 | static UserBuilder _getUser(Store store, String userId) { 38 | return store.state.groupUsers 39 | .firstWhere((user) => user.uid == userId) 40 | .toBuilder(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/routes.dart: -------------------------------------------------------------------------------- 1 | class Routes { 2 | static final home = "/"; 3 | static final login = "/login"; 4 | static final channelNew = "/channel/new"; 5 | static final channelInvite = "/channel/invite"; 6 | static final eventNew = "/event/new"; 7 | static final image = "/image"; 8 | static final imagePinch = "/image/pinch"; 9 | static final imagePicker = "/image/picker"; 10 | static final reaction = "/reaction"; 11 | static final user = "/user"; 12 | static final settings = "/settings"; 13 | } -------------------------------------------------------------------------------- /lib/util/HexColor.dart: -------------------------------------------------------------------------------- 1 | import "dart:ui"; 2 | 3 | class HexColor extends Color { 4 | static int _getColorFromHex(String hexColor) { 5 | hexColor = hexColor.toUpperCase().replaceAll("#", ""); 6 | if (hexColor.length == 6) { 7 | hexColor = "FF" + hexColor; 8 | } 9 | return int.parse(hexColor, radix: 16); 10 | } 11 | 12 | HexColor(final String hexColor) : super(_getColorFromHex(hexColor)); 13 | } -------------------------------------------------------------------------------- /lib/util/cache.dart: -------------------------------------------------------------------------------- 1 | import "dart:collection"; 2 | 3 | /// Basic Cache based on first-in-first-out 4 | /// 5 | /// When the cache gets full (determined by the [size]) it will start 6 | /// removing the older values. 7 | /// 8 | /// Uses a simple [Queue] to store keys, and start removing the first ones 9 | /// in a "first in-first out" manner, until the queue is smaller than the max 10 | /// [size]. 11 | /// 12 | /// This cache can be used for storing in memory thumbnails and similar. 13 | /// 14 | /// Call to [clear] when no longer used. 15 | /// 16 | class BasicCache { 17 | BasicCache({ 18 | this.size, 19 | }); 20 | 21 | final int size; 22 | final _map = Map(); 23 | final _queue = Queue(); 24 | 25 | bool containsKey(K key) { 26 | return _map.containsKey(key); 27 | } 28 | 29 | V operator [](K key) { 30 | return _map[key]; 31 | } 32 | 33 | void operator []=(K key, V value) { 34 | _map[key] = value; 35 | _queue.add(key); 36 | _deleteOldValues(); 37 | } 38 | 39 | void clear() { 40 | _map.clear(); 41 | _queue.clear(); 42 | } 43 | 44 | void _deleteOldValues() { 45 | while (_queue.length > size) { 46 | final key = _queue.removeFirst(); 47 | _map.remove(key); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/util/date_formatting.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/util/logger.dart"; 2 | import "package:flutter/widgets.dart"; 3 | import "package:intl/intl.dart"; 4 | 5 | String formatTime(BuildContext context, DateTime date) { 6 | try { 7 | DateFormat dateFormat; 8 | if (MediaQuery.of(context).alwaysUse24HourFormat) { 9 | dateFormat = DateFormat.Hm(Localizations.localeOf(context).languageCode); 10 | } else { 11 | dateFormat = DateFormat.jm(Localizations.localeOf(context).languageCode); 12 | } 13 | return dateFormat.format(date); 14 | } catch (error) { 15 | Logger.e("Error with time format: $error", e: error, s: StackTrace.current); 16 | return ""; 17 | } 18 | } 19 | 20 | String formatDate(BuildContext context, DateTime date) { 21 | try { 22 | final datePattern = "EEE, MMM d"; 23 | final dateFormat = DateFormat(datePattern, Localizations.localeOf(context).languageCode); 24 | return dateFormat.format(date); 25 | } catch (error) { 26 | Logger.e("Error with date format: $error", e: error, s: StackTrace.current); 27 | return ""; 28 | } 29 | } 30 | 31 | String formatDateShort(BuildContext context, DateTime date) { 32 | try { 33 | final dateFormat = DateFormat.yMd(Localizations.localeOf(context).languageCode); 34 | return dateFormat.format(date); 35 | } catch (error) { 36 | Logger.e("Error with date format: $error", e: error, s: StackTrace.current); 37 | return ""; 38 | } 39 | } 40 | 41 | String formatCalendarDate(BuildContext context, DateTime date) { 42 | try { 43 | final datePattern = "EEEE d. MMM"; 44 | final dateFormat = DateFormat(datePattern, Localizations.localeOf(context).languageCode); 45 | return dateFormat.format(date); 46 | } catch (error) { 47 | Logger.e("Error with calendar date format: $error", e: error, s: StackTrace.current); 48 | return ""; 49 | } 50 | } -------------------------------------------------------------------------------- /lib/util/permissions.dart: -------------------------------------------------------------------------------- 1 | import "dart:io"; 2 | 3 | import "package:circles_app/native_channels/android_permission_channel.dart"; 4 | import "package:circles_app/native_channels/ios_permission_channel.dart"; 5 | import "package:circles_app/util/logger.dart"; 6 | import "package:flutter/services.dart"; 7 | 8 | const MethodChannel channel = MethodChannel("de.janoodle.timy/permission"); 9 | 10 | /// Obtain the status for storage/photos permissions 11 | /// 12 | /// If the app has no permission, it will request it. 13 | /// Uses [PermissionType.Photos] for iOS and Android. 14 | /// 15 | /// For other platforms always returns false. 16 | Future getStoragePermission() async { 17 | if (Platform.isIOS) { 18 | final status = await IOSPermissionChannel.requestPermission( 19 | permissionType: PermissionType.Photos); 20 | Logger.d("Photo permission status: $status"); 21 | return (status == "AUTHORIZED") ? true : false; 22 | } else if (Platform.isAndroid) { 23 | final result = await AndroidPermissionChannel.requestPermission( 24 | permissionType: PermissionType.Photos); 25 | Logger.d("Photo permission status: $result"); 26 | return result; 27 | } 28 | Logger.e("Invalid platform", s: StackTrace.current); 29 | return false; 30 | } 31 | 32 | /// Requests camera permission 33 | /// 34 | /// If the app has no permission, it will request it. 35 | /// Uses [PermissionType.Camera] for iOS. 36 | /// 37 | /// Android always returns true. 38 | /// 39 | /// For other platforms always returns false. 40 | Future getCameraPermission() async { 41 | if (Platform.isIOS) { 42 | final status = await IOSPermissionChannel.requestPermission( 43 | permissionType: PermissionType.Camera); 44 | Logger.w("Photo permission status: $status"); 45 | return (status == "AUTHORIZED") ? true : false; 46 | } else if (Platform.isAndroid) { 47 | // Android does not need permissions to use camera as "Intent" 48 | return true; 49 | } 50 | Logger.e("Invalid platform", s: StackTrace.current); 51 | return false; 52 | } 53 | -------------------------------------------------------------------------------- /res/values/strings_en.arb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/res/values/strings_en.arb -------------------------------------------------------------------------------- /test/data/FirestoreMocks.dart: -------------------------------------------------------------------------------- 1 | import "package:cloud_firestore/cloud_firestore.dart"; 2 | import "package:mockito/mockito.dart"; 3 | 4 | class MockDocumentSnapshot extends Mock implements DocumentSnapshot {} 5 | 6 | class MockSnapshotMetadata extends Mock implements SnapshotMetadata {} 7 | -------------------------------------------------------------------------------- /test/data/circle_repository_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/data/group_repository.dart"; 2 | import "package:circles_app/model/group.dart"; 3 | import "package:cloud_firestore/cloud_firestore.dart"; 4 | import "package:flutter_test/flutter_test.dart"; 5 | import "package:mockito/mockito.dart"; 6 | 7 | 8 | class MockDocumentSnapshot extends Mock implements DocumentSnapshot {} 9 | 10 | main() { 11 | group("Circle Repository", () { 12 | final circle = Group((c) => c 13 | ..id = "ID" 14 | ..name = "CIRCLE" 15 | ..hexColor = "FFFFFF" 16 | ..abbreviation = "CI" 17 | ); 18 | 19 | test("should map DocumentSnapshot to Circle", () { 20 | final document = MockDocumentSnapshot(); 21 | when(document["name"]).thenReturn("CIRCLE"); 22 | when(document.documentID).thenReturn("ID"); 23 | when(document["color"]).thenReturn("FFFFFF"); 24 | when(document["abbreviation"]).thenReturn("CI"); 25 | final outCircle = GroupRepository.fromDoc(document); 26 | expect(outCircle, circle); 27 | }); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /test/data/data_mocks.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/model/channel.dart"; 3 | import "package:circles_app/model/group.dart"; 4 | import "package:circles_app/model/user.dart"; 5 | 6 | final mockUser = User((u) => u 7 | ..uid = "userId" 8 | ..name = "name" 9 | ..email = "email"); 10 | 11 | final mockChannelUser = ChannelUser((u) => u 12 | ..id = "userId" 13 | ..rsvp = RSVP.UNSET); 14 | 15 | final mockChannel = Channel((c) => c 16 | ..id = "channelId" 17 | ..name = "name" 18 | ..visibility = ChannelVisibility.OPEN 19 | ..type = ChannelType.EVENT 20 | ..startDate = DateTime(3000, 1, 1) 21 | ..hasUpdates = false 22 | ..users = ListBuilder([mockChannelUser])); 23 | 24 | final mockGroup = Group((g) => g 25 | ..id = "groupdId" 26 | ..name = "group" 27 | ..channels.replace({"channelId": mockChannel}) 28 | ..abbreviation = "g" 29 | ..hexColor = "" 30 | ..image = ""); 31 | -------------------------------------------------------------------------------- /test/data/user_repository_test.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/data/user_repository.dart"; 3 | import "package:circles_app/model/user.dart"; 4 | import "package:flutter_test/flutter_test.dart"; 5 | import "package:mockito/mockito.dart"; 6 | 7 | import "FirestoreMocks.dart"; 8 | 9 | main() { 10 | group("User Repository", () { 11 | final user = User((u) => u 12 | ..uid = "ID" 13 | ..name = "NAME" 14 | ..email = "EMAIL" 15 | ..unreadUpdates = MapBuilder({})); 16 | 17 | test("should map user to Map", () { 18 | final map = UserRepository.toMap(user); 19 | expect(map, { 20 | "uid": "ID", 21 | "name": "NAME", 22 | "email": "EMAIL", 23 | }); 24 | }); 25 | 26 | test("should map DocumentSnapshot to User", () { 27 | final document = MockDocumentSnapshot(); 28 | when(document["name"]).thenReturn("NAME"); 29 | when(document["email"]).thenReturn("EMAIL"); 30 | when(document.documentID).thenReturn("ID"); 31 | when(document["unreadUpdates"]).thenReturn({}); 32 | final userFromDoc = UserRepository.fromDoc(document); 33 | expect(userFromDoc, user); 34 | }); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /test/domain/auth_reducer_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_reducer.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/authentication/auth_actions.dart"; 4 | import "package:circles_app/model/user.dart"; 5 | import "package:redux/redux.dart"; 6 | import "package:test/test.dart"; 7 | 8 | main() { 9 | group("State Reducer", () { 10 | Store _testStore; 11 | final _testUser = User((u) => u 12 | ..uid = "UID" 13 | ..name = "NAME" 14 | ..email = "EMAIL"); 15 | 16 | setUp(() { 17 | _testStore = Store(appReducer, initialState: AppState.init()); 18 | }); 19 | 20 | test("should load user OnAuthenticated into store", () { 21 | expect(_testStore.state.user, null); 22 | _testStore.dispatch(OnAuthenticated(user: _testUser)); 23 | expect(_testStore.state.user, _testUser); 24 | }); 25 | 26 | test("should remove user OnLogoutSuccess from store", () { 27 | _testStore.dispatch(OnAuthenticated(user: _testUser)); 28 | _testStore.dispatch(OnLogoutSuccess()); 29 | expect(_testStore.state.user, null); 30 | }); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /test/domain/message/message_reducer_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_reducer.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/message/message_actions.dart"; 4 | import "package:circles_app/model/message.dart"; 5 | import "package:circles_app/model/user.dart"; 6 | import "package:flutter_test/flutter_test.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | main() { 10 | group("Message Reducer", () { 11 | final user = User((u) => u 12 | ..uid = "ID" 13 | ..name = "NAME" 14 | ..email = "EMAIL"); 15 | final message = Message((m) => m 16 | ..id = "ID" 17 | ..body = "BODY" 18 | ..authorId = "authorId"); 19 | 20 | test("should update messages", () { 21 | final store = Store( 22 | appReducer, 23 | initialState: 24 | AppState.init().rebuild((a) => a..user = user.toBuilder()), 25 | ); 26 | expect(store.state.messagesOnScreen.isEmpty, true); 27 | store.dispatch(UpdateAllMessages([message])); 28 | expect(store.state.messagesOnScreen, [message]); 29 | }); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /test/domain/push/push_reducer_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_reducer.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/push/push_actions.dart"; 4 | import "package:circles_app/model/channel.dart"; 5 | import "package:circles_app/model/in_app_notification.dart"; 6 | import "package:flutter_test/flutter_test.dart"; 7 | import "package:redux/redux.dart"; 8 | 9 | main() { 10 | group("Push Reducer", () { 11 | final notification = InAppNotification((i) => i 12 | ..message = "HELLO" 13 | ..userName = "USERNAME" 14 | ..groupName = "CIRCLENAME" 15 | ..groupId = "GROUPID" 16 | ..channel.update((c) => c 17 | ..type = ChannelType.TOPIC 18 | ..name = "CHANNEL NAME" 19 | ..visibility = ChannelVisibility.OPEN)); 20 | 21 | test("should update AppState with push notification", () { 22 | final store = Store( 23 | appReducer, 24 | initialState: AppState.init(), 25 | ); 26 | expect(store.state.inAppNotification, null); 27 | store.dispatch(ShowPushNotificationAction(notification)); 28 | expect(store.state.inAppNotification, notification); 29 | }); 30 | 31 | test("should update AppState when push notification dismissed", () { 32 | final store = Store( 33 | appReducer, 34 | initialState: AppState.init() 35 | .rebuild((a) => a..inAppNotification = notification.toBuilder()), 36 | ); 37 | expect(store.state.inAppNotification, notification); 38 | store.dispatch(OnPushNotificationDismissedAction()); 39 | expect(store.state.inAppNotification, null); 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /test/domain/redux_mocks.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/data/channel_repository.dart"; 2 | import "package:circles_app/data/group_repository.dart"; 3 | import "package:circles_app/data/file_repository.dart"; 4 | import "package:circles_app/data/message_repository.dart"; 5 | import "package:circles_app/data/user_repository.dart"; 6 | import "package:circles_app/domain/redux/app_state.dart"; 7 | import "package:firebase_messaging/firebase_messaging.dart"; 8 | import "package:flutter/foundation.dart"; 9 | import "package:mockito/mockito.dart"; 10 | import "package:redux/redux.dart"; 11 | import "package:flutter/widgets.dart" as w; 12 | 13 | class MockGroupRepository extends Mock implements GroupRepository {} 14 | 15 | class MockChannelsRepository extends Mock implements ChannelRepository {} 16 | 17 | class MockMessageRepository extends Mock implements MessageRepository {} 18 | 19 | class MockUserRepository extends Mock implements UserRepository {} 20 | 21 | class MockFileRepository extends Mock implements FileRepository {} 22 | 23 | class MockFirebaseMessaging extends Mock implements FirebaseMessaging {} 24 | 25 | class MockMiddleware extends Mock implements MiddlewareClass {} 26 | 27 | // ignore: must_be_immutable 28 | class MockGlobalKey extends Mock implements w.GlobalKey {} 29 | 30 | class MockNavigatorState extends Mock implements w.NavigatorState { 31 | @override 32 | // ignore: invalid_override_different_default_values_named 33 | String toString({DiagnosticLevel minLevel}) => ""; 34 | } 35 | -------------------------------------------------------------------------------- /test/domain/user/user_middleware_test.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | import "package:circles_app/domain/redux/app_reducer.dart"; 4 | import "package:circles_app/domain/redux/app_state.dart"; 5 | import "package:circles_app/domain/redux/authentication/auth_actions.dart"; 6 | import "package:circles_app/domain/redux/user/user_actions.dart"; 7 | import "package:circles_app/domain/redux/user/user_middleware.dart"; 8 | import "package:circles_app/model/user.dart"; 9 | import "package:flutter_test/flutter_test.dart"; 10 | import "package:matcher/matcher.dart"; 11 | import "package:mockito/mockito.dart"; 12 | import "package:redux/redux.dart"; 13 | 14 | import "../redux_mocks.dart"; 15 | 16 | main() { 17 | group("User Middleware", () { 18 | final userRepo = MockUserRepository(); 19 | final captor = MockMiddleware(); 20 | final user = User((u) => u 21 | ..uid = "ID" 22 | ..name = "NAME" 23 | ..email = "EMAIL"); 24 | final store = Store( 25 | appReducer, 26 | initialState: AppState.init(), 27 | middleware: createUserMiddleware(userRepo)..add(captor), 28 | ); 29 | 30 | test("Should update user", () { 31 | final controller = StreamController(sync: true); 32 | when(userRepo.getUserStream("ID")).thenAnswer((_) => controller.stream); 33 | 34 | store.dispatch(OnAuthenticated(user: user)); 35 | controller.add(user); 36 | 37 | verify( 38 | captor.call( 39 | any, 40 | TypeMatcher(), 41 | any, 42 | ) as dynamic, 43 | ); 44 | 45 | controller.close(); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/domain/user/user_reducer_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_reducer.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/domain/redux/user/user_actions.dart"; 4 | import "package:circles_app/model/user.dart"; 5 | import "package:flutter_test/flutter_test.dart"; 6 | import "package:redux/redux.dart"; 7 | 8 | main() { 9 | group("User Reducer", () { 10 | // Ids of user and userOld are the same 11 | final user = User((u) => u 12 | ..uid = "userId" 13 | ..name = "name" 14 | ..image = "imageUrl" 15 | ..status = "myStatus" 16 | ..email = "my@example.com"); 17 | 18 | final userOld = User((u) => u 19 | ..uid = "userId" 20 | ..name = "nameOld" 21 | ..image = "imageUrlOld" 22 | ..status = "myStatusOld" 23 | ..email = "my@example.com"); 24 | 25 | test("should update user when state empty", () { 26 | final store = Store( 27 | appReducer, 28 | initialState: AppState.init(), 29 | ); 30 | 31 | expect(store.state.user, null); 32 | expect(store.state.groupUsers.contains(user), false); 33 | store.dispatch(OnUserUpdateAction(user)); 34 | expect(store.state.user, user); 35 | expect(store.state.groupUsers.contains(user), true); 36 | }); 37 | 38 | test("should update user when state has data", () { 39 | final store = Store( 40 | appReducer, 41 | initialState: AppState.init(), 42 | ); 43 | 44 | // Load old user first 45 | store.dispatch(OnUserUpdateAction(userOld)); 46 | expect(store.state.user, userOld); 47 | expect(store.state.groupUsers.contains(userOld), true); 48 | expect(store.state.groupUsers.contains(user), false); 49 | 50 | // Update user (e.g. changes status or name) 51 | store.dispatch(OnUserUpdateAction(user)); 52 | expect(store.state.user, user); 53 | expect(store.state.groupUsers.contains(userOld), false); 54 | expect(store.state.groupUsers.contains(user), true); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /test/model/message_test.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/model/message.dart"; 3 | import "package:circles_app/model/reaction.dart"; 4 | import "package:flutter_test/flutter_test.dart"; 5 | 6 | main() { 7 | group("Message Model", () { 8 | final message = Message((m) => m 9 | ..body = "" 10 | ..authorId = "USERID" 11 | ..reactions = BuiltMap.of({ 12 | "USER1": Reaction((r) => r 13 | ..emoji = "❤️" 14 | ..userId = "USERID" 15 | ..timestamp = DateTime.now() 16 | ..userName = "USERNAME"), 17 | "USER2": Reaction((r) => r 18 | ..emoji = "❤️" 19 | ..userId = "USERID" 20 | ..timestamp = DateTime.now() 21 | ..userName = "USERNAME"), 22 | "USER3": Reaction((r) => r 23 | ..emoji = "😂" 24 | ..userId = "USERID" 25 | ..timestamp = DateTime.now() 26 | ..userName = "USERNAME"), 27 | }).toBuilder()); 28 | 29 | test("should count emoji in reactions", () { 30 | final reactions = message.reactionsCount(); 31 | expect(reactions, { 32 | "😂": 1, 33 | "❤️": 2, 34 | }); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/presentation/channel/invite/invite_to_channel_viewmodel_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/domain/redux/app_reducer.dart"; 2 | import "package:circles_app/domain/redux/app_state.dart"; 3 | import "package:circles_app/model/channel.dart"; 4 | import "package:circles_app/model/group.dart"; 5 | import "package:circles_app/model/user.dart"; 6 | import "package:circles_app/presentation/channel/invite/invite_to_channel_viewmodel.dart"; 7 | import "package:flutter_test/flutter_test.dart"; 8 | import "package:redux/redux.dart"; 9 | 10 | main() { 11 | group("Invite To Channel ViewModel", () { 12 | test("should list users that are not already in the channel", () { 13 | final user = User((u) => u 14 | ..uid = "user" 15 | ..email = "email" 16 | ..name = "name"); 17 | 18 | // Group has three users: 1, 2, 3 19 | final user1 = user.rebuild((u) => u..uid = "user1"); 20 | final user2 = user.rebuild((u) => u..uid = "user2"); 21 | final user3 = user.rebuild((u) => u..uid = "user3"); 22 | 23 | // User 1 and 2 are already in the channel 24 | final channelUser1 = ChannelUser((u) => u 25 | ..id = "user1" 26 | ..rsvp = RSVP.UNSET); 27 | final channelUser2 = ChannelUser((u) => u 28 | ..id = "user2" 29 | ..rsvp = RSVP.UNSET); 30 | 31 | final store = Store(appReducer, 32 | initialState: AppState.init().rebuild((s) => s 33 | ..groupUsers.replace([user1, user2, user3]) 34 | ..selectedGroupId = "groupId" 35 | ..groups.replace({ 36 | "groupId": Group((g) => g 37 | ..id = "groupId" 38 | ..name = "group" 39 | ..hexColor = "" 40 | ..abbreviation = "" 41 | ..channels.replace({ 42 | "channelId": Channel((c) => c 43 | ..id = "channelId" 44 | ..name = "name" 45 | ..visibility = ChannelVisibility.CLOSED 46 | ..type = ChannelType.EVENT 47 | ..users.replace([channelUser1, channelUser2])) 48 | })) 49 | }))); 50 | 51 | final vm = InviteToChannelViewModel.fromStore("channelId")(store); 52 | 53 | // Only user 3 should appear in the list 54 | expect(vm.newUsers, [user3]); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /test/presentation/reaction/reaction_details_test.dart: -------------------------------------------------------------------------------- 1 | import "package:built_collection/built_collection.dart"; 2 | import "package:circles_app/model/reaction.dart"; 3 | import "package:circles_app/presentation/channel/reaction/emoji_picker.dart"; 4 | import "package:circles_app/presentation/channel/reaction/reaction_detail_data.dart"; 5 | import "package:circles_app/presentation/channel/reaction/reaction_details.dart"; 6 | import "package:flutter_test/flutter_test.dart"; 7 | 8 | main() { 9 | group("Reaction details tests", () { 10 | test("should sort reaction details as expected", () { 11 | final map = BuiltMap({ 12 | "USER2": Reaction((r) => r 13 | ..userId = "USERID" 14 | ..userName = "Miguel" 15 | ..timestamp = DateTime.now() 16 | ..emoji = emojiPickerOptions[2]), 17 | "USER4": Reaction((r) => r 18 | ..userId = "USERID" 19 | ..userName = "Lara" 20 | ..timestamp = DateTime(2019) 21 | ..emoji = emojiPickerOptions[2]), 22 | "USER1": Reaction((r) => r 23 | ..userId = "USERID" 24 | ..userName = "Lily" 25 | ..timestamp = DateTime.now() 26 | ..emoji = emojiPickerOptions[1]), 27 | "USER5": Reaction((r) => r 28 | ..userId = "USERID" 29 | ..userName = "Droid" 30 | ..timestamp = DateTime.now() 31 | ..emoji = emojiPickerOptions[0]), 32 | }); 33 | 34 | final details = toListOfReactionDetailData(map); 35 | 36 | expect(details, [ 37 | ReactionDetailData((r) => r 38 | ..emoji = emojiPickerOptions[2] 39 | ..names = "Miguel, Lara"), 40 | ReactionDetailData((r) => r 41 | ..emoji = emojiPickerOptions[0] 42 | ..names = "Droid"), 43 | ReactionDetailData((r) => r 44 | ..emoji = emojiPickerOptions[1] 45 | ..names = "Lily"), 46 | ]); 47 | }); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /test/synchronous_error.dart: -------------------------------------------------------------------------------- 1 | import "dart:async"; 2 | 3 | class SynchronousError implements Future { 4 | 5 | final Object _error; 6 | 7 | SynchronousError(this._error); 8 | 9 | @override 10 | Stream asStream() { 11 | final StreamController controller = StreamController(); 12 | controller.addError(_error); 13 | controller.close(); 14 | return controller.stream; 15 | } 16 | 17 | @override 18 | Future catchError(Function onError, { bool test(dynamic error) }) { 19 | onError(_error); 20 | return Completer().future; 21 | } 22 | 23 | @override 24 | Future then(dynamic f(T value), { Function onError }) { 25 | return SynchronousError(_error); 26 | } 27 | 28 | @override 29 | Future timeout(Duration timeLimit, { dynamic onTimeout() }) { 30 | return Future.error(_error).timeout(timeLimit, onTimeout: onTimeout); 31 | } 32 | 33 | @override 34 | Future whenComplete(dynamic action()) { 35 | try { 36 | return this; 37 | } catch (e, stack) { 38 | return Future.error(e, stack); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/widget_test.dart: -------------------------------------------------------------------------------- 1 | import "package:circles_app/circles_app.dart"; 2 | import "package:flutter_test/flutter_test.dart"; 3 | 4 | void main() { 5 | testWidgets("App loads test", (WidgetTester tester) async { 6 | // Build our app and trigger a frame. 7 | await tester.pumpWidget(CirclesApp()); 8 | 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /timy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janoodleFTW/timy-messenger/1442eb4a52169fb865f40a50fa1606b9499d6f4d/timy.png --------------------------------------------------------------------------------