diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00d9b50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Desktop & Web related +**/GeneratedPlugins.props +**/generated_plugin_registrant.cc +**/generated_plugins.mk +**/generated_plugin_registrant.dart + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +# Sample data +sample_data +spikes/contacts_creator/*.csv + +# Firebase +/.firebase +.firebaserc +firebase.json diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..4cb000a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,9 @@ +Copyright 2020 gskinner.com, inc. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md index 5b572d0..27402f1 100644 --- a/README.md +++ b/README.md @@ -1 +1,83 @@ -Chirp? +# Flokk +A fresh and modern Google Contacts manager that integrates with GitHub and Twitter. + +## Demo Builds +- Web: https://flokk.app +- Linux: https://snapcraft.io/flokk-contacts +- macOS: https://flokk.app/macos/Flokk_Contacts_v1.0.1.dmg +- Windows: https://flokk.app/windows/Flokk_Contacts_v1.0.1.zip + +## Getting Set Up + +### 1. Flutter + +- Follow the install instructions here: https://flutter.dev/docs/get-started/install + - Desktop-specific info: https://flutter.dev/desktop, https://github.com/flutter/flutter/wiki/Desktop-shells +- Flokk was built on the bleeding edge of Flutter, so make sure to use their the `master` branch in their git repo and checkout commit `9c3f0faa6d`. + - We're aiming to upgrade to an official Flutter version soon to make this easier. + +### 2. Add Required API Keys + +Google Sign In is required in order to run the app (unless running with [cached data](#running-with-cached-data)). You will need to provide your own Google API keys in the `/lib/api_keys.dart` file. Follow the instructions here to create them: +- https://developers.google.com/people/v1/how-tos/authorizing#APIKey + +To optionally fetch social data for your contacts, add your own API keys for those as well: +- Twitter: https://developer.twitter.com/en/docs/basics/getting-started +- GitHub: https://developer.github.com/v3/guides/basics-of-authentication/ + +Although the Twitter and GitHub keys are optional, they are recommended. Otherwise the app will not be able to fetch tweets and GitHub calls will be subject to a rate limit (https://developer.github.com/v3/#rate-limiting). + +### Web Builds +If you're building for web: +- Edit `/web/index.html` to include your web credentials (web client Id) ``. +- This is needed for Google Sign In to work on web builds. For more details, see https://pub.dev/packages/google_sign_in_web + +#### CORS Proxy +For Twitter support to work on web builds, it is necessary to use a CORS proxy. You can set up a proxy on your own domain, or else run a localhost instance with `proxy/app.js`. + +If setting up on your domain, ensure you have enabled https (https://letsencrypt.org/). Once you have the security certificate, edit `proxy/app.js` and insert your cert and key. This is not necessary if running a localhost instance. + +After the proxy is set up, edit `services/twitter_rest_service.dart` to point to your running proxy instance (e.g. "https://my-proxy.com", "http://localhost", etc.) + +For more information, see https://github.com/Rob--W/cors-anywhere + +## Running With Cached Data +If you simply want to see the app running, it is possible to run the app using cached data: +- Run the app at least once, to create your data folders +- Extract the _contents_ of /sample_data.zip to the newly created data folder on your machine: + - Windows: \Users\\[USER]\AppData\Roaming\Flokk Contacts + - Linux: $HOME/.local/share/flokk-contacts + - macOS: /Users/[USER]/Library/Containers/app.flokk/Data/Library/Application Support/app.flokk +- Overwrite any files that are there +- Launch the app again, it should now sign in, and load with existing data. +- If you sign out, your saved data will be wiped and you will need to repeat the process. + +Note: This is meant as a 'read-only' mode so you can quickly explore all the widgets and features of the app. Updates, deletes, etc are not expected to work. + +## Test & Build +Debug Builds +- Use `flutter run -d DEVICE_ID` to deploy a test build +- To get a list of available DEVICE_ID, use `flutter run` +- Typical values are: `windows`, `linux`, `macos`, `chrome` +- Add `--release` to deploy an optimized build + +Release Builds +- Use `flutter build PACKAGE_TYPE` to build a release package +- To get a list of available PACKAGE_TYPE, use `flutter build` +- Typical values are `windows`, `linux`, `apk`, `ios` +- In order to build the snap package one must first run `lxd init` on your system if you haven't already. + ** Then execute `build.sh` to create the snap from the flutter build + +Force Log In +- The app uses a `kForceWebLogin` flag to force release builds to skip the oauth screen. +- Add `--dart-define=flokk.forceWebLogin=true` to your build command to enable +- E.g. `flutter build web --dart-define=flokk.forceWebLogin=true` + +## Desktop Runners +The /linux and /windows folders contain modifications and should not be upgraded to upstream without first verifying that each plugin works correctly with the new upstream code and any modifications are made. + +Since the desktop runners for this project may contain modifications, upgrades should not be made without first verifying that every plugin and embedder can be upgraded and that they remain compatible after an upgrade. Hopefully this will not be as much of an issue once Flutter's desktop support becomes more mature. + +-- + +Happy Flokking! diff --git a/flokk_src/.gitignore b/flokk_src/.gitignore new file mode 100644 index 0000000..96ed2b3 --- /dev/null +++ b/flokk_src/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +deploy/ +.firebase/ + +# Web related +lib/generated_plugin_registrant.dart + +# Generated files +lib/hidden_api_keys.dart +linux/flutter/generated_plugins.mk + +# Symbolication related +app.*.symbols + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/flokk_src/.metadata b/flokk_src/.metadata new file mode 100644 index 0000000..b4e9f8a --- /dev/null +++ b/flokk_src/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: ec9813a5005f4c3e75a5a9f42ce53ae280959085 + channel: master + +project_type: app diff --git a/flokk_src/.vscode/launch.json b/flokk_src/.vscode/launch.json new file mode 100644 index 0000000..3287bb6 --- /dev/null +++ b/flokk_src/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Flutter", + "request": "launch", + "type": "dart" + } + ] +} \ No newline at end of file diff --git a/flokk_src/README.md b/flokk_src/README.md new file mode 100644 index 0000000..e8e427e --- /dev/null +++ b/flokk_src/README.md @@ -0,0 +1,2 @@ +See the README on the main git repo. + \ No newline at end of file diff --git a/flokk_src/analysis_options.yaml b/flokk_src/analysis_options.yaml new file mode 100644 index 0000000..034f400 --- /dev/null +++ b/flokk_src/analysis_options.yaml @@ -0,0 +1,61 @@ + + +exclude: + - "strings.dart" + +analyzer: + strong-mode: + implicit-dynamic: true + implicit-casts: true + errors: + unused_import: warning + unused_local_variable: warning + dead_code: warning + enable-experiment: + - extension-methods + +# Lint rules and documentation, see http://dart-lang.github.io/linter/lints +linter: + rules: + - annotate_overrides + #- avoid_unused_constructor_parameters + - await_only_futures + - camel_case_types + - cancel_subscriptions + - directives_ordering + - empty_catches + - empty_statements + - hash_and_equals + - iterable_contains_unrelated_type + - list_remove_unrelated_type + - no_adjacent_strings_in_list + - no_duplicate_case_values + #- non_constant_identifier_names + - only_throw_errors + - overridden_fields + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_contains + - prefer_final_fields + #- prefer_final_locals + - prefer_initializing_formals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_typing_uninitialized_variables + - recursive_getters + - slash_for_doc_comments + - test_types_in_equals + - throw_in_finally + - type_init_formals + #- unawaited_futures + - unnecessary_brace_in_string_interps + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_statements + - unnecessary_this + - unrelated_type_equality_checks + - use_rethrow_when_possible + - valid_regexps \ No newline at end of file diff --git a/flokk_src/assets/fonts/Lato-Black.ttf b/flokk_src/assets/fonts/Lato-Black.ttf new file mode 100644 index 0000000..a4a924f Binary files /dev/null and b/flokk_src/assets/fonts/Lato-Black.ttf differ diff --git a/flokk_src/assets/fonts/Lato-Bold.ttf b/flokk_src/assets/fonts/Lato-Bold.ttf new file mode 100644 index 0000000..b63a14d Binary files /dev/null and b/flokk_src/assets/fonts/Lato-Bold.ttf differ diff --git a/flokk_src/assets/fonts/Lato-Light.ttf b/flokk_src/assets/fonts/Lato-Light.ttf new file mode 100644 index 0000000..9c0a705 Binary files /dev/null and b/flokk_src/assets/fonts/Lato-Light.ttf differ diff --git a/flokk_src/assets/fonts/Lato-Regular.ttf b/flokk_src/assets/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..33eba8b Binary files /dev/null and b/flokk_src/assets/fonts/Lato-Regular.ttf differ diff --git a/flokk_src/assets/fonts/Lato-Thin.ttf b/flokk_src/assets/fonts/Lato-Thin.ttf new file mode 100644 index 0000000..0c599a0 Binary files /dev/null and b/flokk_src/assets/fonts/Lato-Thin.ttf differ diff --git a/flokk_src/assets/fonts/OpenSansEmoji.ttf b/flokk_src/assets/fonts/OpenSansEmoji.ttf new file mode 100644 index 0000000..57d86a6 Binary files /dev/null and b/flokk_src/assets/fonts/OpenSansEmoji.ttf differ diff --git a/flokk_src/assets/fonts/Quicksand-Bold.ttf b/flokk_src/assets/fonts/Quicksand-Bold.ttf new file mode 100644 index 0000000..49326cd Binary files /dev/null and b/flokk_src/assets/fonts/Quicksand-Bold.ttf differ diff --git a/flokk_src/assets/fonts/Quicksand-Light.ttf b/flokk_src/assets/fonts/Quicksand-Light.ttf new file mode 100644 index 0000000..42ef072 Binary files /dev/null and b/flokk_src/assets/fonts/Quicksand-Light.ttf differ diff --git a/flokk_src/assets/fonts/Quicksand-Medium.ttf b/flokk_src/assets/fonts/Quicksand-Medium.ttf new file mode 100644 index 0000000..7dc8c27 Binary files /dev/null and b/flokk_src/assets/fonts/Quicksand-Medium.ttf differ diff --git a/flokk_src/assets/fonts/Quicksand-Regular.ttf b/flokk_src/assets/fonts/Quicksand-Regular.ttf new file mode 100644 index 0000000..9fdce17 Binary files /dev/null and b/flokk_src/assets/fonts/Quicksand-Regular.ttf differ diff --git a/flokk_src/assets/fonts/Quicksand-SemiBold.ttf b/flokk_src/assets/fonts/Quicksand-SemiBold.ttf new file mode 100644 index 0000000..bc9a8ab Binary files /dev/null and b/flokk_src/assets/fonts/Quicksand-SemiBold.ttf differ diff --git a/flokk_src/assets/icons/2.0x/icon-add.png b/flokk_src/assets/icons/2.0x/icon-add.png new file mode 100644 index 0000000..7c4ae27 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-add.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-address.png b/flokk_src/assets/icons/2.0x/icon-address.png new file mode 100644 index 0000000..365caa2 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-address.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-birthday.png b/flokk_src/assets/icons/2.0x/icon-birthday.png new file mode 100644 index 0000000..056af7d Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-birthday.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-calendar.png b/flokk_src/assets/icons/2.0x/icon-calendar.png new file mode 100644 index 0000000..ecda3ab Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-calendar.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-checkbox-partial.png b/flokk_src/assets/icons/2.0x/icon-checkbox-partial.png new file mode 100644 index 0000000..5165819 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-checkbox-partial.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-checkbox-selected.png b/flokk_src/assets/icons/2.0x/icon-checkbox-selected.png new file mode 100644 index 0000000..12ad8f0 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-checkbox-selected.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-close-large.png b/flokk_src/assets/icons/2.0x/icon-close-large.png new file mode 100644 index 0000000..bcb5187 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-close-large.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-copy.png b/flokk_src/assets/icons/2.0x/icon-copy.png new file mode 100644 index 0000000..ee2054b Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-copy.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-darkmode.png b/flokk_src/assets/icons/2.0x/icon-darkmode.png new file mode 100644 index 0000000..f290ca6 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-darkmode.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-dashboard.png b/flokk_src/assets/icons/2.0x/icon-dashboard.png new file mode 100644 index 0000000..6f44a8e Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-dashboard.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-dropdown-close.png b/flokk_src/assets/icons/2.0x/icon-dropdown-close.png new file mode 100644 index 0000000..6da7d3f Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-dropdown-close.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-dropdown-open.png b/flokk_src/assets/icons/2.0x/icon-dropdown-open.png new file mode 100644 index 0000000..d15c184 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-dropdown-open.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-edit.png b/flokk_src/assets/icons/2.0x/icon-edit.png new file mode 100644 index 0000000..00b4b52 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-edit.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-form-add.png b/flokk_src/assets/icons/2.0x/icon-form-add.png new file mode 100644 index 0000000..43b783a Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-form-add.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-form-addlabel.png b/flokk_src/assets/icons/2.0x/icon-form-addlabel.png new file mode 100644 index 0000000..cdb8b6c Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-form-addlabel.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-form-delete.png b/flokk_src/assets/icons/2.0x/icon-form-delete.png new file mode 100644 index 0000000..f9938c3 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-form-delete.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-github-active.png b/flokk_src/assets/icons/2.0x/icon-github-active.png new file mode 100644 index 0000000..b8ee5b4 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-github-active.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-github-empty.png b/flokk_src/assets/icons/2.0x/icon-github-empty.png new file mode 100644 index 0000000..80b0a12 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-github-empty.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-label.png b/flokk_src/assets/icons/2.0x/icon-label.png new file mode 100644 index 0000000..0a00a19 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-label.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-lightmode.png b/flokk_src/assets/icons/2.0x/icon-lightmode.png new file mode 100644 index 0000000..39d7c3b Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-lightmode.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-link.png b/flokk_src/assets/icons/2.0x/icon-link.png new file mode 100644 index 0000000..90b22c4 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-link.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-linkout.png b/flokk_src/assets/icons/2.0x/icon-linkout.png new file mode 100644 index 0000000..f49462f Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-linkout.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-mail.png b/flokk_src/assets/icons/2.0x/icon-mail.png new file mode 100644 index 0000000..ecdc874 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-mail.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-next.png b/flokk_src/assets/icons/2.0x/icon-next.png new file mode 100644 index 0000000..2589151 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-next.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-note.png b/flokk_src/assets/icons/2.0x/icon-note.png new file mode 100644 index 0000000..286c218 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-note.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-phone.png b/flokk_src/assets/icons/2.0x/icon-phone.png new file mode 100644 index 0000000..6ee2ce9 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-phone.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-previous.png b/flokk_src/assets/icons/2.0x/icon-previous.png new file mode 100644 index 0000000..51f70af Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-previous.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-refresh.png b/flokk_src/assets/icons/2.0x/icon-refresh.png new file mode 100644 index 0000000..8267ef6 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-refresh.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-relationship.png b/flokk_src/assets/icons/2.0x/icon-relationship.png new file mode 100644 index 0000000..9b71ecb Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-relationship.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-save.png b/flokk_src/assets/icons/2.0x/icon-save.png new file mode 100644 index 0000000..2da7a4b Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-save.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-search.png b/flokk_src/assets/icons/2.0x/icon-search.png new file mode 100644 index 0000000..727a3bb Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-search.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-setting.png b/flokk_src/assets/icons/2.0x/icon-setting.png new file mode 100644 index 0000000..4db18b3 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-setting.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-signout.png b/flokk_src/assets/icons/2.0x/icon-signout.png new file mode 100644 index 0000000..08f8fd7 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-signout.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-social-fork.png b/flokk_src/assets/icons/2.0x/icon-social-fork.png new file mode 100644 index 0000000..71f2515 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-social-fork.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-social-like.png b/flokk_src/assets/icons/2.0x/icon-social-like.png new file mode 100644 index 0000000..c767798 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-social-like.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-social-retweet.png b/flokk_src/assets/icons/2.0x/icon-social-retweet.png new file mode 100644 index 0000000..4d0baa1 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-social-retweet.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-social-star.png b/flokk_src/assets/icons/2.0x/icon-social-star.png new file mode 100644 index 0000000..cdd46db Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-social-star.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-star-empty.png b/flokk_src/assets/icons/2.0x/icon-star-empty.png new file mode 100644 index 0000000..3cb48c7 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-star-empty.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-star-filled.png b/flokk_src/assets/icons/2.0x/icon-star-filled.png new file mode 100644 index 0000000..cdd46db Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-star-filled.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-trash.png b/flokk_src/assets/icons/2.0x/icon-trash.png new file mode 100644 index 0000000..344ad72 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-trash.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-twitter-active.png b/flokk_src/assets/icons/2.0x/icon-twitter-active.png new file mode 100644 index 0000000..a229626 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-twitter-active.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-twitter-empty.png b/flokk_src/assets/icons/2.0x/icon-twitter-empty.png new file mode 100644 index 0000000..805746a Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-twitter-empty.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-user.png b/flokk_src/assets/icons/2.0x/icon-user.png new file mode 100644 index 0000000..1db2517 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-user.png differ diff --git a/flokk_src/assets/icons/2.0x/icon-work.png b/flokk_src/assets/icons/2.0x/icon-work.png new file mode 100644 index 0000000..c157f57 Binary files /dev/null and b/flokk_src/assets/icons/2.0x/icon-work.png differ diff --git a/flokk_src/assets/icons/icon-add.png b/flokk_src/assets/icons/icon-add.png new file mode 100644 index 0000000..f9fa685 Binary files /dev/null and b/flokk_src/assets/icons/icon-add.png differ diff --git a/flokk_src/assets/icons/icon-address.png b/flokk_src/assets/icons/icon-address.png new file mode 100644 index 0000000..1a806b6 Binary files /dev/null and b/flokk_src/assets/icons/icon-address.png differ diff --git a/flokk_src/assets/icons/icon-birthday.png b/flokk_src/assets/icons/icon-birthday.png new file mode 100644 index 0000000..0db5e61 Binary files /dev/null and b/flokk_src/assets/icons/icon-birthday.png differ diff --git a/flokk_src/assets/icons/icon-calendar.png b/flokk_src/assets/icons/icon-calendar.png new file mode 100644 index 0000000..fcaeae6 Binary files /dev/null and b/flokk_src/assets/icons/icon-calendar.png differ diff --git a/flokk_src/assets/icons/icon-checkbox-partial.png b/flokk_src/assets/icons/icon-checkbox-partial.png new file mode 100644 index 0000000..4798e2d Binary files /dev/null and b/flokk_src/assets/icons/icon-checkbox-partial.png differ diff --git a/flokk_src/assets/icons/icon-checkbox-selected.png b/flokk_src/assets/icons/icon-checkbox-selected.png new file mode 100644 index 0000000..8680d37 Binary files /dev/null and b/flokk_src/assets/icons/icon-checkbox-selected.png differ diff --git a/flokk_src/assets/icons/icon-close-large.png b/flokk_src/assets/icons/icon-close-large.png new file mode 100644 index 0000000..7742950 Binary files /dev/null and b/flokk_src/assets/icons/icon-close-large.png differ diff --git a/flokk_src/assets/icons/icon-copy.png b/flokk_src/assets/icons/icon-copy.png new file mode 100644 index 0000000..e0545e5 Binary files /dev/null and b/flokk_src/assets/icons/icon-copy.png differ diff --git a/flokk_src/assets/icons/icon-darkmode.png b/flokk_src/assets/icons/icon-darkmode.png new file mode 100644 index 0000000..87cb863 Binary files /dev/null and b/flokk_src/assets/icons/icon-darkmode.png differ diff --git a/flokk_src/assets/icons/icon-dashboard.png b/flokk_src/assets/icons/icon-dashboard.png new file mode 100644 index 0000000..2b54de8 Binary files /dev/null and b/flokk_src/assets/icons/icon-dashboard.png differ diff --git a/flokk_src/assets/icons/icon-dropdown-close.png b/flokk_src/assets/icons/icon-dropdown-close.png new file mode 100644 index 0000000..68c022e Binary files /dev/null and b/flokk_src/assets/icons/icon-dropdown-close.png differ diff --git a/flokk_src/assets/icons/icon-dropdown-open.png b/flokk_src/assets/icons/icon-dropdown-open.png new file mode 100644 index 0000000..7493174 Binary files /dev/null and b/flokk_src/assets/icons/icon-dropdown-open.png differ diff --git a/flokk_src/assets/icons/icon-edit.png b/flokk_src/assets/icons/icon-edit.png new file mode 100644 index 0000000..0d28f79 Binary files /dev/null and b/flokk_src/assets/icons/icon-edit.png differ diff --git a/flokk_src/assets/icons/icon-form-add.png b/flokk_src/assets/icons/icon-form-add.png new file mode 100644 index 0000000..d7d7f95 Binary files /dev/null and b/flokk_src/assets/icons/icon-form-add.png differ diff --git a/flokk_src/assets/icons/icon-form-addlabel.png b/flokk_src/assets/icons/icon-form-addlabel.png new file mode 100644 index 0000000..5053e64 Binary files /dev/null and b/flokk_src/assets/icons/icon-form-addlabel.png differ diff --git a/flokk_src/assets/icons/icon-form-delete.png b/flokk_src/assets/icons/icon-form-delete.png new file mode 100644 index 0000000..5be5cb9 Binary files /dev/null and b/flokk_src/assets/icons/icon-form-delete.png differ diff --git a/flokk_src/assets/icons/icon-github-active.png b/flokk_src/assets/icons/icon-github-active.png new file mode 100644 index 0000000..f89feba Binary files /dev/null and b/flokk_src/assets/icons/icon-github-active.png differ diff --git a/flokk_src/assets/icons/icon-github-empty.png b/flokk_src/assets/icons/icon-github-empty.png new file mode 100644 index 0000000..a4fd00e Binary files /dev/null and b/flokk_src/assets/icons/icon-github-empty.png differ diff --git a/flokk_src/assets/icons/icon-label.png b/flokk_src/assets/icons/icon-label.png new file mode 100644 index 0000000..68022f4 Binary files /dev/null and b/flokk_src/assets/icons/icon-label.png differ diff --git a/flokk_src/assets/icons/icon-lightmode.png b/flokk_src/assets/icons/icon-lightmode.png new file mode 100644 index 0000000..d5eb4e6 Binary files /dev/null and b/flokk_src/assets/icons/icon-lightmode.png differ diff --git a/flokk_src/assets/icons/icon-link.png b/flokk_src/assets/icons/icon-link.png new file mode 100644 index 0000000..75081c7 Binary files /dev/null and b/flokk_src/assets/icons/icon-link.png differ diff --git a/flokk_src/assets/icons/icon-linkout.png b/flokk_src/assets/icons/icon-linkout.png new file mode 100644 index 0000000..878aadd Binary files /dev/null and b/flokk_src/assets/icons/icon-linkout.png differ diff --git a/flokk_src/assets/icons/icon-mail.png b/flokk_src/assets/icons/icon-mail.png new file mode 100644 index 0000000..baeed99 Binary files /dev/null and b/flokk_src/assets/icons/icon-mail.png differ diff --git a/flokk_src/assets/icons/icon-next.png b/flokk_src/assets/icons/icon-next.png new file mode 100644 index 0000000..2b830dc Binary files /dev/null and b/flokk_src/assets/icons/icon-next.png differ diff --git a/flokk_src/assets/icons/icon-note.png b/flokk_src/assets/icons/icon-note.png new file mode 100644 index 0000000..7b42354 Binary files /dev/null and b/flokk_src/assets/icons/icon-note.png differ diff --git a/flokk_src/assets/icons/icon-phone.png b/flokk_src/assets/icons/icon-phone.png new file mode 100644 index 0000000..5958b37 Binary files /dev/null and b/flokk_src/assets/icons/icon-phone.png differ diff --git a/flokk_src/assets/icons/icon-previous.png b/flokk_src/assets/icons/icon-previous.png new file mode 100644 index 0000000..cf48722 Binary files /dev/null and b/flokk_src/assets/icons/icon-previous.png differ diff --git a/flokk_src/assets/icons/icon-refresh.png b/flokk_src/assets/icons/icon-refresh.png new file mode 100644 index 0000000..16fb326 Binary files /dev/null and b/flokk_src/assets/icons/icon-refresh.png differ diff --git a/flokk_src/assets/icons/icon-relationship.png b/flokk_src/assets/icons/icon-relationship.png new file mode 100644 index 0000000..f696b02 Binary files /dev/null and b/flokk_src/assets/icons/icon-relationship.png differ diff --git a/flokk_src/assets/icons/icon-save.png b/flokk_src/assets/icons/icon-save.png new file mode 100644 index 0000000..3ed5b10 Binary files /dev/null and b/flokk_src/assets/icons/icon-save.png differ diff --git a/flokk_src/assets/icons/icon-search.png b/flokk_src/assets/icons/icon-search.png new file mode 100644 index 0000000..871e0d8 Binary files /dev/null and b/flokk_src/assets/icons/icon-search.png differ diff --git a/flokk_src/assets/icons/icon-setting.png b/flokk_src/assets/icons/icon-setting.png new file mode 100644 index 0000000..bc6b05f Binary files /dev/null and b/flokk_src/assets/icons/icon-setting.png differ diff --git a/flokk_src/assets/icons/icon-signout.png b/flokk_src/assets/icons/icon-signout.png new file mode 100644 index 0000000..97041b1 Binary files /dev/null and b/flokk_src/assets/icons/icon-signout.png differ diff --git a/flokk_src/assets/icons/icon-social-fork.png b/flokk_src/assets/icons/icon-social-fork.png new file mode 100644 index 0000000..e27041f Binary files /dev/null and b/flokk_src/assets/icons/icon-social-fork.png differ diff --git a/flokk_src/assets/icons/icon-social-like.png b/flokk_src/assets/icons/icon-social-like.png new file mode 100644 index 0000000..0a58575 Binary files /dev/null and b/flokk_src/assets/icons/icon-social-like.png differ diff --git a/flokk_src/assets/icons/icon-social-retweet.png b/flokk_src/assets/icons/icon-social-retweet.png new file mode 100644 index 0000000..43196c8 Binary files /dev/null and b/flokk_src/assets/icons/icon-social-retweet.png differ diff --git a/flokk_src/assets/icons/icon-social-star.png b/flokk_src/assets/icons/icon-social-star.png new file mode 100644 index 0000000..45730d7 Binary files /dev/null and b/flokk_src/assets/icons/icon-social-star.png differ diff --git a/flokk_src/assets/icons/icon-star-empty.png b/flokk_src/assets/icons/icon-star-empty.png new file mode 100644 index 0000000..df85baf Binary files /dev/null and b/flokk_src/assets/icons/icon-star-empty.png differ diff --git a/flokk_src/assets/icons/icon-star-filled.png b/flokk_src/assets/icons/icon-star-filled.png new file mode 100644 index 0000000..45730d7 Binary files /dev/null and b/flokk_src/assets/icons/icon-star-filled.png differ diff --git a/flokk_src/assets/icons/icon-trash.png b/flokk_src/assets/icons/icon-trash.png new file mode 100644 index 0000000..8c05313 Binary files /dev/null and b/flokk_src/assets/icons/icon-trash.png differ diff --git a/flokk_src/assets/icons/icon-twitter-active.png b/flokk_src/assets/icons/icon-twitter-active.png new file mode 100644 index 0000000..919d791 Binary files /dev/null and b/flokk_src/assets/icons/icon-twitter-active.png differ diff --git a/flokk_src/assets/icons/icon-twitter-empty.png b/flokk_src/assets/icons/icon-twitter-empty.png new file mode 100644 index 0000000..a84bc1d Binary files /dev/null and b/flokk_src/assets/icons/icon-twitter-empty.png differ diff --git a/flokk_src/assets/icons/icon-user.png b/flokk_src/assets/icons/icon-user.png new file mode 100644 index 0000000..af608c5 Binary files /dev/null and b/flokk_src/assets/icons/icon-user.png differ diff --git a/flokk_src/assets/icons/icon-work.png b/flokk_src/assets/icons/icon-work.png new file mode 100644 index 0000000..f10e923 Binary files /dev/null and b/flokk_src/assets/icons/icon-work.png differ diff --git a/flokk_src/assets/images/avatars/Bear.png b/flokk_src/assets/images/avatars/Bear.png new file mode 100644 index 0000000..7bd16a6 Binary files /dev/null and b/flokk_src/assets/images/avatars/Bear.png differ diff --git a/flokk_src/assets/images/avatars/Camel.png b/flokk_src/assets/images/avatars/Camel.png new file mode 100644 index 0000000..a145b7b Binary files /dev/null and b/flokk_src/assets/images/avatars/Camel.png differ diff --git a/flokk_src/assets/images/avatars/Fox.png b/flokk_src/assets/images/avatars/Fox.png new file mode 100644 index 0000000..a844f72 Binary files /dev/null and b/flokk_src/assets/images/avatars/Fox.png differ diff --git a/flokk_src/assets/images/avatars/Giraff.png b/flokk_src/assets/images/avatars/Giraff.png new file mode 100644 index 0000000..d059846 Binary files /dev/null and b/flokk_src/assets/images/avatars/Giraff.png differ diff --git a/flokk_src/assets/images/avatars/Monkey.png b/flokk_src/assets/images/avatars/Monkey.png new file mode 100644 index 0000000..700b19d Binary files /dev/null and b/flokk_src/assets/images/avatars/Monkey.png differ diff --git a/flokk_src/assets/images/avatars/Tiger.png b/flokk_src/assets/images/avatars/Tiger.png new file mode 100644 index 0000000..bcfe8a6 Binary files /dev/null and b/flokk_src/assets/images/avatars/Tiger.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg0.png b/flokk_src/assets/images/avatars/avatar_bg0.png new file mode 100644 index 0000000..ce41c31 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg0.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg1.png b/flokk_src/assets/images/avatars/avatar_bg1.png new file mode 100644 index 0000000..eff8720 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg1.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg2.png b/flokk_src/assets/images/avatars/avatar_bg2.png new file mode 100644 index 0000000..38c95ab Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg2.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg3.png b/flokk_src/assets/images/avatars/avatar_bg3.png new file mode 100644 index 0000000..89c18f4 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg3.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg4.png b/flokk_src/assets/images/avatars/avatar_bg4.png new file mode 100644 index 0000000..e828de0 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg4.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg5.png b/flokk_src/assets/images/avatars/avatar_bg5.png new file mode 100644 index 0000000..51d6647 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg5.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg6.png b/flokk_src/assets/images/avatars/avatar_bg6.png new file mode 100644 index 0000000..3ea02ba Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg6.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg7.png b/flokk_src/assets/images/avatars/avatar_bg7.png new file mode 100644 index 0000000..c5c4eca Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg7.png differ diff --git a/flokk_src/assets/images/avatars/avatar_bg8.png b/flokk_src/assets/images/avatars/avatar_bg8.png new file mode 100644 index 0000000..a7f3b47 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_bg8.png differ diff --git a/flokk_src/assets/images/avatars/avatar_fg0.png b/flokk_src/assets/images/avatars/avatar_fg0.png new file mode 100644 index 0000000..8158286 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_fg0.png differ diff --git a/flokk_src/assets/images/avatars/avatar_fg1.png b/flokk_src/assets/images/avatars/avatar_fg1.png new file mode 100644 index 0000000..fc3e234 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_fg1.png differ diff --git a/flokk_src/assets/images/avatars/avatar_fg2.png b/flokk_src/assets/images/avatars/avatar_fg2.png new file mode 100644 index 0000000..e4ba64d Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_fg2.png differ diff --git a/flokk_src/assets/images/avatars/avatar_fg3.png b/flokk_src/assets/images/avatars/avatar_fg3.png new file mode 100644 index 0000000..a756534 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_fg3.png differ diff --git a/flokk_src/assets/images/avatars/avatar_fg4.png b/flokk_src/assets/images/avatars/avatar_fg4.png new file mode 100644 index 0000000..021eeb2 Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_fg4.png differ diff --git a/flokk_src/assets/images/avatars/avatar_fg5.png b/flokk_src/assets/images/avatars/avatar_fg5.png new file mode 100644 index 0000000..b9822eb Binary files /dev/null and b/flokk_src/assets/images/avatars/avatar_fg5.png differ diff --git a/flokk_src/assets/images/birds/bird-flamingo.png b/flokk_src/assets/images/birds/bird-flamingo.png new file mode 100644 index 0000000..610825f Binary files /dev/null and b/flokk_src/assets/images/birds/bird-flamingo.png differ diff --git a/flokk_src/assets/images/birds/bird-hummingbird.png b/flokk_src/assets/images/birds/bird-hummingbird.png new file mode 100644 index 0000000..d19ca59 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-hummingbird.png differ diff --git a/flokk_src/assets/images/birds/bird-owl.png b/flokk_src/assets/images/birds/bird-owl.png new file mode 100644 index 0000000..de6c1d6 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-owl.png differ diff --git a/flokk_src/assets/images/birds/bird-parrot.png b/flokk_src/assets/images/birds/bird-parrot.png new file mode 100644 index 0000000..96577d4 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-parrot.png differ diff --git a/flokk_src/assets/images/birds/bird-peacock.png b/flokk_src/assets/images/birds/bird-peacock.png new file mode 100644 index 0000000..036e622 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-peacock.png differ diff --git a/flokk_src/assets/images/birds/bird-pelican.png b/flokk_src/assets/images/birds/bird-pelican.png new file mode 100644 index 0000000..2ea2299 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-pelican.png differ diff --git a/flokk_src/assets/images/birds/bird-penguin.png b/flokk_src/assets/images/birds/bird-penguin.png new file mode 100644 index 0000000..513e196 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-penguin.png differ diff --git a/flokk_src/assets/images/birds/bird-swan.png b/flokk_src/assets/images/birds/bird-swan.png new file mode 100644 index 0000000..9805a7b Binary files /dev/null and b/flokk_src/assets/images/birds/bird-swan.png differ diff --git a/flokk_src/assets/images/birds/bird-toucan.png b/flokk_src/assets/images/birds/bird-toucan.png new file mode 100644 index 0000000..5b7a1ee Binary files /dev/null and b/flokk_src/assets/images/birds/bird-toucan.png differ diff --git a/flokk_src/assets/images/birds/bird-woodpecker.png b/flokk_src/assets/images/birds/bird-woodpecker.png new file mode 100644 index 0000000..42569f4 Binary files /dev/null and b/flokk_src/assets/images/birds/bird-woodpecker.png differ diff --git a/flokk_src/assets/images/empty-dashboard-favorites@2x.png b/flokk_src/assets/images/empty-dashboard-favorites@2x.png new file mode 100644 index 0000000..4de4933 Binary files /dev/null and b/flokk_src/assets/images/empty-dashboard-favorites@2x.png differ diff --git a/flokk_src/assets/images/empty-dashboard-github@2x.png b/flokk_src/assets/images/empty-dashboard-github@2x.png new file mode 100644 index 0000000..f1c633e Binary files /dev/null and b/flokk_src/assets/images/empty-dashboard-github@2x.png differ diff --git a/flokk_src/assets/images/empty-dashboard-recentActive@2x.png b/flokk_src/assets/images/empty-dashboard-recentActive@2x.png new file mode 100644 index 0000000..1e5d7b8 Binary files /dev/null and b/flokk_src/assets/images/empty-dashboard-recentActive@2x.png differ diff --git a/flokk_src/assets/images/empty-dashboard-twitter@2x.png b/flokk_src/assets/images/empty-dashboard-twitter@2x.png new file mode 100644 index 0000000..4fe55c3 Binary files /dev/null and b/flokk_src/assets/images/empty-dashboard-twitter@2x.png differ diff --git a/flokk_src/assets/images/empty-noresult-bg@2x.png b/flokk_src/assets/images/empty-noresult-bg@2x.png new file mode 100644 index 0000000..201dfb7 Binary files /dev/null and b/flokk_src/assets/images/empty-noresult-bg@2x.png differ diff --git a/flokk_src/assets/images/empty-noresult-owl@2x.png b/flokk_src/assets/images/empty-noresult-owl@2x.png new file mode 100644 index 0000000..b8435a5 Binary files /dev/null and b/flokk_src/assets/images/empty-noresult-owl@2x.png differ diff --git a/flokk_src/assets/images/flokk-app-logo.png b/flokk_src/assets/images/flokk-app-logo.png new file mode 100644 index 0000000..34ccffe Binary files /dev/null and b/flokk_src/assets/images/flokk-app-logo.png differ diff --git a/flokk_src/assets/images/flokk-logo.png b/flokk_src/assets/images/flokk-logo.png new file mode 100644 index 0000000..709be68 Binary files /dev/null and b/flokk_src/assets/images/flokk-logo.png differ diff --git a/flokk_src/assets/images/flokk-logo.svg b/flokk_src/assets/images/flokk-logo.svg new file mode 100644 index 0000000..3dd9bab --- /dev/null +++ b/flokk_src/assets/images/flokk-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flokk_src/assets/images/google-signin.png b/flokk_src/assets/images/google-signin.png new file mode 100644 index 0000000..6ec94b1 Binary files /dev/null and b/flokk_src/assets/images/google-signin.png differ diff --git a/flokk_src/assets/images/onboarding-bg.png b/flokk_src/assets/images/onboarding-bg.png new file mode 100644 index 0000000..825b94a Binary files /dev/null and b/flokk_src/assets/images/onboarding-bg.png differ diff --git a/flokk_src/assets/images/onboarding-birds.png b/flokk_src/assets/images/onboarding-birds.png new file mode 100644 index 0000000..6128546 Binary files /dev/null and b/flokk_src/assets/images/onboarding-birds.png differ diff --git a/flokk_src/assets/images/onboarding-clouds.png b/flokk_src/assets/images/onboarding-clouds.png new file mode 100644 index 0000000..4fe8eae Binary files /dev/null and b/flokk_src/assets/images/onboarding-clouds.png differ diff --git a/flokk_src/assets/images/sidebar-bg.png b/flokk_src/assets/images/sidebar-bg.png new file mode 100644 index 0000000..322980b Binary files /dev/null and b/flokk_src/assets/images/sidebar-bg.png differ diff --git a/flokk_src/assets/images/sidebar-logo.png b/flokk_src/assets/images/sidebar-logo.png new file mode 100644 index 0000000..e2fd3f8 Binary files /dev/null and b/flokk_src/assets/images/sidebar-logo.png differ diff --git a/flokk_src/lib/_internal/abstract_api_keys.dart b/flokk_src/lib/_internal/abstract_api_keys.dart new file mode 100644 index 0000000..4e9203f --- /dev/null +++ b/flokk_src/lib/_internal/abstract_api_keys.dart @@ -0,0 +1,15 @@ +abstract class AbstractApiKeys { + String get googleClientId; + + String get googleClientSecret; + + String get googleWebClientId; + + String get twitterKey; + + String get twitterSecret; + + String get githubKey; + + String get githubSecret; +} diff --git a/flokk_src/lib/_internal/components/animated_panel.dart b/flokk_src/lib/_internal/components/animated_panel.dart new file mode 100644 index 0000000..07bf4bf --- /dev/null +++ b/flokk_src/lib/_internal/components/animated_panel.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +/// An animated sliding container, optimized to hide it's children when closed. +class AnimatedPanel extends StatefulWidget { + final bool isClosed; + final double closedX; + final double closedY; + final double duration; + final Curve curve; + final Widget child; + + const AnimatedPanel({Key key, this.isClosed, this.closedX, this.closedY, this.duration, this.curve, this.child}) + : super(key: key); + + @override + _AnimatedPanelState createState() => _AnimatedPanelState(); +} + +class _AnimatedPanelState extends State { + bool _isHidden = true; + + @override + Widget build(BuildContext context) { + Offset closePos = Offset(widget.closedX ?? 0, widget.closedY ?? 0); + double duration = _isHidden && widget.isClosed ? 0 : widget.duration; + return TweenAnimationBuilder( + curve: widget.curve ?? Curves.easeOut, + tween: Tween( + begin: !widget.isClosed ? Offset.zero : closePos, + end: !widget.isClosed ? Offset.zero : closePos, + ), + duration: Duration(milliseconds: (duration * 1000).round()), + builder: (_, Offset value, Widget c) { + _isHidden = widget.isClosed && value == Offset(widget.closedX, widget.closedY); + return _isHidden ? Container() : Transform.translate(offset: value, child: c); + }, + child: widget.child, + ); + } +} + +extension AnimatedPanelExtensions on Widget { + Widget animatedPanelX({double closeX, bool isClosed, double duration = .35, Curve curve}) => + animatedPanel(closePos: Offset(closeX, 0), isClosed: isClosed, curve: curve, duration: duration); + + Widget animatedPanelY({double closeY, bool isClosed, double duration = .35, Curve curve}) => + animatedPanel(closePos: Offset(0, closeY), isClosed: isClosed, curve: curve, duration: duration); + + Widget animatedPanel({Offset closePos, bool isClosed, double duration = .35, Curve curve}) { + return AnimatedPanel( + closedX: closePos.dx, closedY: closePos.dy, child: this, isClosed: isClosed, duration: duration, curve: curve); + } +} diff --git a/flokk_src/lib/_internal/components/animated_state.dart b/flokk_src/lib/_internal/components/animated_state.dart new file mode 100644 index 0000000..6c28e05 --- /dev/null +++ b/flokk_src/lib/_internal/components/animated_state.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class AnimatedTextSpike extends StatefulWidget { + @override + _AnimatedTextSpikeState createState() => _AnimatedTextSpikeState(); +} + +class _AnimatedTextSpikeState extends AnimatedState { + @override + void initAnimation() => animation = createAnim(seconds: 4)..forward(); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: animation.value, + child: GestureDetector( + onTap: () => animation.forward(from: 0), + child: Text("Hello Fade:"), + ), + ); + } +} + +abstract class AnimatedState extends State with SingleTickerProviderStateMixin { + AnimationController animation; + + AnimationController createAnim({double lowerBound = 0, double upperBound = 1, double seconds = .2}) { + return AnimationController( + vsync: this, + duration: Duration(milliseconds: (seconds * 1000).round()), + lowerBound: lowerBound, + upperBound: upperBound, + )..addListener(() => setState(() {})); + } + + void initAnimation() => animation = createAnim(); + + @override + void initState() { + initAnimation(); + super.initState(); + } + + @override + void dispose() { + animation?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context); +} diff --git a/flokk_src/lib/_internal/components/clickable_extensions.dart b/flokk_src/lib/_internal/components/clickable_extensions.dart new file mode 100644 index 0000000..91a25ed --- /dev/null +++ b/flokk_src/lib/_internal/components/clickable_extensions.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +extension ClickableExtensions on Widget { + Widget clickable(void Function() action, {bool opaque = true}) { + return GestureDetector( + behavior: opaque? HitTestBehavior.opaque : HitTestBehavior.deferToChild, + onTap: action, + child: MouseRegion( + cursor: SystemMouseCursors.click, + opaque: opaque ?? false, + child: this, + ), + ); + } +} diff --git a/flokk_src/lib/_internal/components/content_underlay.dart b/flokk_src/lib/_internal/components/content_underlay.dart new file mode 100644 index 0000000..7ee12f4 --- /dev/null +++ b/flokk_src/lib/_internal/components/content_underlay.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class ContentUnderlay extends StatelessWidget { + final Color color; + final bool isActive; + final Duration duration; + final bool animate; + + const ContentUnderlay({Key key, this.color, this.isActive = true, this.duration, this.animate}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: isActive ? 1 : 0, end: isActive ? 1 : 0), + duration: duration ?? Duration(milliseconds: 350), + builder: (_, double opacity, __) { + return opacity == 0 + // Don't return anything if we're totally invisible + ? SizedBox.shrink() + // Use a RawMaterialButton to stop hover events to passing to buttons below + : RawMaterialButton( + padding: EdgeInsets.zero, + onPressed: null, + child: Opacity( + opacity: opacity, + child: Container( + color: (color ?? Colors.black.withOpacity(.8)), + ), + ), + ); + }, + ); + } +} diff --git a/flokk_src/lib/_internal/components/delayed_builder.dart b/flokk_src/lib/_internal/components/delayed_builder.dart new file mode 100644 index 0000000..fcbadaa --- /dev/null +++ b/flokk_src/lib/_internal/components/delayed_builder.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:time/time.dart'; + +class DelayedBuilder extends StatefulWidget { + final WidgetBuilder firstBuilder; + final WidgetBuilder secondBuilder; + final double delay; + + const DelayedBuilder({Key key, this.firstBuilder, this.secondBuilder, this.delay}) : super(key: key); + + @override + _DelayedBuilderState createState() => _DelayedBuilderState(); +} + +class _DelayedBuilderState extends State { + bool show = false; + bool initComplete = false; + + @override + void initState() { + Future.delayed((widget.delay ?? 0).milliseconds).then((value) { + if (!mounted) return; + return setState(() => show = true); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) => !show ? widget.firstBuilder(context) : widget.secondBuilder(context); +} diff --git a/flokk_src/lib/_internal/components/design_grid_overlay.dart b/flokk_src/lib/_internal/components/design_grid_overlay.dart new file mode 100644 index 0000000..d0e7b77 --- /dev/null +++ b/flokk_src/lib/_internal/components/design_grid_overlay.dart @@ -0,0 +1,101 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flutter/material.dart'; + +class GridLayout { + final EdgeInsets gutters; + final double padding; + final int numCols; + final double breakPt; + + GridLayout({this.gutters, this.padding, this.numCols, this.breakPt}); +} + +class DesignGridOverlay extends StatefulWidget { + final Widget child; + + final Alignment alignment; + final List grids; + final bool isEnabled; + + DesignGridOverlay({Key key, this.child, this.grids, this.isEnabled = true, this.alignment}) : super(key: key); + + @override + _DesignGridOverlayState createState() => _DesignGridOverlayState(); +} + +class _DesignGridOverlayState extends State { + double gridAlpha = 0; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return widget.isEnabled + ? Stack( + children: [ + widget.child, + //Main View + _DesignGridView(this), + ], + ) + : widget.child; + } + + void handleTap() => setState(() => gridAlpha >= 1 ? gridAlpha = 0 : gridAlpha += .48); +} + +class _DesignGridView extends StatelessWidget { + final _DesignGridOverlayState state; + + const _DesignGridView(this.state, {Key key}) : super(key: key); + + GridLayout getGrid(BuildContext context) { + for (var i = 0; i < state.widget.grids.length; i++) { + final List grids = state.widget.grids; + if (grids[i].breakPt >= context.widthPx) return grids[i]; + } + return state.widget.grids.last; + } + + @override + Widget build(BuildContext context) { + final GridLayout grid = getGrid(context); + final List content = [Container(width: grid.padding)]; + final int numCols = grid.numCols; + for (var i = numCols; i-- > 0;) { + content.add( + Flexible(child: Container(color: Colors.red.withOpacity(state.gridAlpha * .4), height: double.infinity))); + content.add(Container(width: grid.padding)); + } + print("CurrentBreak: ${grid.breakPt}"); + return Stack(children: [ + if (state.gridAlpha > 0) + IgnorePointer( + child: Padding( + padding: grid.gutters ?? EdgeInsets.zero, + child: Row(children: content), + ), + ), + Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(" ${context.widthPx} x ${context.heightPx} ", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 10)), + Text( + " ${(context.widthInches).toStringAsPrecision(3)}'' x ${(context.heightInches).toStringAsPrecision(3)}'' ", + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 10)), + ], + ).padding(all: 4), + ) + .gestures( + onTapUp: (d) => state.handleTap(), + behavior: HitTestBehavior.opaque, + ) + .alignment(state.widget.alignment), + ]); + } +} diff --git a/flokk_src/lib/_internal/components/fading_index_stack.dart b/flokk_src/lib/_internal/components/fading_index_stack.dart new file mode 100644 index 0000000..bcb51b7 --- /dev/null +++ b/flokk_src/lib/_internal/components/fading_index_stack.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:time/time.dart'; + +class FadingIndexedStack extends StatefulWidget { + final int index; + final List children; + final Duration duration; + + const FadingIndexedStack({ + Key key, + this.index, + this.children, + this.duration = const Duration( + milliseconds: 250, + ), + }) : super(key: key); + + @override + _FadingIndexedStackState createState() => _FadingIndexedStackState(); +} + +class _FadingIndexedStackState extends State { + double _targetOpacity = 1; + + @override + void didUpdateWidget(FadingIndexedStack oldWidget) { + if (oldWidget.index == widget.index) return; + setState(() => _targetOpacity = 0); + Future.delayed(1.milliseconds, () => setState(() => _targetOpacity = 1)); + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + duration: _targetOpacity > 0 ? widget.duration : 0.milliseconds, + tween: Tween(begin: 0, end: _targetOpacity), + builder: (_, value, child) { + return Opacity(opacity: value, child: child); + }, + child: IndexedStack(index: widget.index, children: widget.children), + ); + } +} diff --git a/flokk_src/lib/_internal/components/listenable_builder.dart b/flokk_src/lib/_internal/components/listenable_builder.dart new file mode 100644 index 0000000..36e9b4f --- /dev/null +++ b/flokk_src/lib/_internal/components/listenable_builder.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class ListenableBuilder extends AnimatedBuilder { + ListenableBuilder({ + Key key, + @required Listenable listenable, + @required TransitionBuilder builder, + Widget child, + }) : super(key: key, animation: listenable, builder: builder, child: child); +} diff --git a/flokk_src/lib/_internal/components/mouse_hover_builder.dart b/flokk_src/lib/_internal/components/mouse_hover_builder.dart new file mode 100644 index 0000000..1c4915a --- /dev/null +++ b/flokk_src/lib/_internal/components/mouse_hover_builder.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +typedef Widget HoverBuilder(BuildContext context, bool isHovering); + +class MouseHoverBuilder extends StatefulWidget { + final bool isClickable; + + MouseHoverBuilder({Key key, this.builder, this.isClickable = false}) : super(key: key); + + final HoverBuilder builder; + + @override + _MouseHoverBuilderState createState() => _MouseHoverBuilderState(); +} + +class _MouseHoverBuilderState extends State { + bool isOver = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: widget.isClickable? SystemMouseCursors.click : SystemMouseCursors.basic, + onEnter: (p) => setOver(true), + onExit: (p) => setOver(false), + child: widget.builder(context, isOver), + ); + } + + void setOver(bool value) => setState(() => isOver = value); +} diff --git a/flokk_src/lib/_internal/components/multi_value_listenable.dart b/flokk_src/lib/_internal/components/multi_value_listenable.dart new file mode 100644 index 0000000..01fe769 --- /dev/null +++ b/flokk_src/lib/_internal/components/multi_value_listenable.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class ValueListenableBuilder2 extends StatelessWidget { + ValueListenableBuilder2({Key key, this.value1, this.value2, this.builder, this.child}) : super(key: key); + + final ValueListenable value1; + final ValueListenable value2; + final Widget child; + final Widget Function(BuildContext context, A a, B b, Widget child) builder; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: value1, + builder: (_, a, __) => ValueListenableBuilder( + valueListenable: value2, + builder: (context, b, __) => builder(context, a, b, child), + ), + ); + } +} + +class ValueListenableBuilder3 extends StatelessWidget { + ValueListenableBuilder3({Key key, this.value1, this.value2, this.value3, this.builder, this.child}) : super(key: key); + + final ValueListenable value1; + final ValueListenable value2; + final ValueListenable value3; + final Widget child; + final Widget Function(BuildContext context, A a, B b, C c, Widget child) builder; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: value1, + builder: (_, a, __) => ValueListenableBuilder2( + value1: value2, + value2: value3, + builder: (context, b, c, __) => builder(context, a, b, c, child), + ), + ); + } +} + +class ValueListenableBuilder4 extends StatelessWidget { + ValueListenableBuilder4(this.value1, this.value2, this.value3, this.value4, {Key key, this.builder, this.child}) + : super(key: key); + + final ValueListenable value1; + final ValueListenable value2; + final ValueListenable value3; + final ValueListenable value4; + + final Widget child; + final Widget Function(BuildContext context, A a, B b, C c, D d, Widget child) builder; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder2( + value1: value1, + value2: value2, + builder: (_, a, b, __) => ValueListenableBuilder2( + value1: value3, + value2: value4, + builder: (context, c, d, __) => builder(context, a, b, c, d, child), + ), + ); + } +} diff --git a/flokk_src/lib/_internal/components/no_glow_scroll_behavior.dart b/flokk_src/lib/_internal/components/no_glow_scroll_behavior.dart new file mode 100644 index 0000000..4482df9 --- /dev/null +++ b/flokk_src/lib/_internal/components/no_glow_scroll_behavior.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class NoGlowScrollBehavior extends ScrollBehavior { + @override + Widget buildViewportChrome(BuildContext context, Widget child, AxisDirection axisDirection) { + return child; + } +} diff --git a/flokk_src/lib/_internal/components/one_line_text.dart b/flokk_src/lib/_internal/components/one_line_text.dart new file mode 100644 index 0000000..02d8e69 --- /dev/null +++ b/flokk_src/lib/_internal/components/one_line_text.dart @@ -0,0 +1,13 @@ + +import 'package:flutter/material.dart'; + +class OneLineText extends StatelessWidget { + final String text; + final TextStyle style; + + const OneLineText(this.text, {Key key, this.style}) : super(key: key); + + @override + Widget build(BuildContext context) => + Text(text, style: style, maxLines: 1, softWrap: false, overflow: TextOverflow.fade); +} diff --git a/flokk_src/lib/_internal/components/pinned.dart b/flokk_src/lib/_internal/components/pinned.dart new file mode 100644 index 0000000..8024c20 --- /dev/null +++ b/flokk_src/lib/_internal/components/pinned.dart @@ -0,0 +1,92 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'pinned_stack.dart'; + +class Pin { + final double startPx; + final double startPct; + final double endPx; + final double endPct; + final double sizePx; + final double centerPct; + + Pin({this.startPx, this.startPct, this.endPx, this.endPct, this.sizePx, this.centerPct}); +} + +class Pinned extends StatelessWidget { + final Pin hzPin; + final Pin vtPin; + final Widget child; + + const Pinned({Key key, this.hzPin, this.vtPin, this.child}) : super(key: key); + + _Span calculateSpanFromPin(Pin pin, double maxSize) { + var s = _Span(); + //Size is unknown, so we must be pinned on both sides + if (pin.sizePx == null) { + s.start = pin.startPx ?? pin.startPct * maxSize; + s.end = maxSize - (pin.endPx ?? pin.endPct * maxSize); + } + //We know the size, figure out which side we're pinned on, if any + else { + //Pinned to start + if (pin.startPx != null || pin.startPct != null) { + s.start = pin.startPx ?? pin.startPct * maxSize; + s.end = s.start + pin.sizePx; + } + //Pinned to end + else if (pin.endPx != null || pin.endPct != null) { + s.end = maxSize - (pin.endPx ?? pin.endPct * maxSize); + s.start = s.end - pin.sizePx; + } + //Both sides are % pinned, use center - size/2 to position + else { + s.start = (pin.centerPct * maxSize) - pin.sizePx * .5; + s.end = s.start + pin.sizePx; + } + } + return s; + } + + @override + Widget build(BuildContext context) { + //Check to see if we have been provided some StackConstraints by [ PinnedStack ] + StackConstraints constraints = context.dependOnInheritedWidgetOfExactType(); + if (constraints != null) { + return _buildContent(constraints.constraints); + } + //If not, we need to find our own constraints + else { + return LayoutBuilder( + builder: (context, constraints) => _buildContent(constraints), + ); + } + } + + Widget _buildContent(BoxConstraints constraints) { + _Span hzSpan = calculateSpanFromPin(hzPin, constraints.maxWidth); + _Span vtSpan = calculateSpanFromPin(vtPin, constraints.maxHeight); + //Hide child if either dimension is 0 + bool showChild = (hzSpan.size > 0 && vtSpan.size > 0); + return Transform.translate( + offset: Offset(hzSpan.start, vtSpan.start), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: hzSpan.size, height: vtSpan.size, child: showChild ? child : null), + ), + ); + } +} + +class _Span { + double start; + double end; + + double get size => max(0, end - start); +} + +extension PinnedExtensions on Widget { + Pinned pin({Pin hz, Pin vt}) => Pinned(hzPin: hz, vtPin: vt, child: this); +} diff --git a/flokk_src/lib/_internal/components/pinned_stack.dart b/flokk_src/lib/_internal/components/pinned_stack.dart new file mode 100644 index 0000000..63ab34e --- /dev/null +++ b/flokk_src/lib/_internal/components/pinned_stack.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class StackConstraints extends InheritedWidget { + final BoxConstraints constraints; + + StackConstraints({this.constraints, Widget child}) : super(child: child); + + @override + bool updateShouldNotify(InheritedWidget oldWidget) { + var old = (oldWidget as StackConstraints).constraints; + return old.maxWidth != constraints.maxWidth || old.maxHeight != constraints.maxHeight; + } +} + +class PinnedStack extends StatelessWidget { + final List children; + final StackFit fit; + final AlignmentGeometry alignment; + final TextDirection textDirection; + final Overflow overflow; + + const PinnedStack( + {Key key, + this.children, + this.fit = StackFit.expand, + this.alignment = Alignment.topLeft, + this.textDirection = TextDirection.ltr, + this.overflow = Overflow.visible}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, constraints) { + return StackConstraints( + constraints: constraints, + child: Stack( + fit: fit, + alignment: alignment, + overflow: overflow, + textDirection: textDirection, + children: children, + ), + ); + }); + } +} diff --git a/flokk_src/lib/_internal/components/rotation_3d.dart b/flokk_src/lib/_internal/components/rotation_3d.dart new file mode 100644 index 0000000..ccf8db7 --- /dev/null +++ b/flokk_src/lib/_internal/components/rotation_3d.dart @@ -0,0 +1,29 @@ +//Takes a x,y or z rotation, in degrees, and rotates. Good for spins & 3d flip effects +import 'dart:math'; + +import 'package:flutter/material.dart'; + +class Rotation3d extends StatelessWidget { + //Degrees to rads constant + static const double degrees2Radians = pi / 180; + + final Widget child; + final double rotationX; + final double rotationY; + final double rotationZ; + + const Rotation3d({Key key, @required this.child, this.rotationX = 0, this.rotationY = 0, this.rotationZ = 0}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Transform( + alignment: FractionalOffset.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateX(rotationX * degrees2Radians) + ..rotateY(rotationY * degrees2Radians) + ..rotateZ(rotationZ * degrees2Radians), + child: child); + } +} diff --git a/flokk_src/lib/_internal/components/scrolling_flex_view.dart b/flokk_src/lib/_internal/components/scrolling_flex_view.dart new file mode 100644 index 0000000..67de8d5 --- /dev/null +++ b/flokk_src/lib/_internal/components/scrolling_flex_view.dart @@ -0,0 +1,35 @@ +import 'package:flokk/styled_components/scrolling/styled_scrollview.dart'; +import 'package:flutter/material.dart'; + +class ConstrainedFlexView extends StatelessWidget { + final Widget child; + final double minSize; + final Axis axis; + final EdgeInsets scrollPadding; + + const ConstrainedFlexView(this.minSize, {Key key, this.child, this.axis, this.scrollPadding}) : super(key: key); + + bool get isHz => axis == Axis.horizontal; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) { + double viewSize = isHz ? constraints.maxWidth : constraints.maxHeight; + if (viewSize > minSize) return child; + return Padding( + padding: scrollPadding, + child: StyledScrollView( + axis: axis ?? Axis.vertical, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: isHz ? double.infinity : minSize, + maxWidth: isHz ? minSize : double.infinity), + child: child, + ), + ), + ); + }, + ); + } +} diff --git a/flokk_src/lib/_internal/components/selectable_link_text.dart b/flokk_src/lib/_internal/components/selectable_link_text.dart new file mode 100644 index 0000000..d160818 --- /dev/null +++ b/flokk_src/lib/_internal/components/selectable_link_text.dart @@ -0,0 +1,77 @@ +import 'package:flokk/_internal/url_launcher/url_launcher.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class SelectableLinkText extends StatefulWidget { + final String text; + final TextStyle textStyle; + final TextStyle linkStyle; + final TextAlign textAlign; + + const SelectableLinkText({ + Key key, + @required this.text, + this.textStyle, + this.linkStyle, + this.textAlign = TextAlign.start, + }) : assert(text != null), + super(key: key); + + @override + _LinkTextState createState() => _LinkTextState(); +} + +class _LinkTextState extends State { + List _gestureRecognizers; + final RegExp _regex = RegExp( + r"https?:\/\/(www\.)?[-a-zA-Z0-9@:%.,_\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\,+.~#?&//=]*)"); + + @override + void initState() { + super.initState(); + _gestureRecognizers = []; + } + + @override + void dispose() { + _gestureRecognizers.forEach((recognizer) => recognizer.dispose()); + super.dispose(); + } + + void _launchUrl(String url) async { + UrlLauncher.openHttp(url); + } + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + final textStyle = widget.textStyle ?? themeData.textTheme.body1; + final linkStyle = widget.linkStyle ?? + themeData.textTheme.bodyText1.copyWith(color: themeData.accentColor, decoration: TextDecoration.underline); + + final links = _regex.allMatches(widget.text); + + if (links.isEmpty) { + return SelectableText(widget.text, style: textStyle); + } + + final textParts = widget.text.split(_regex); + final textSpans = []; + + int i = 0; + textParts.forEach((part) { + textSpans.add(TextSpan(text: part, style: textStyle)); + if (i < links.length) { + final link = links.elementAt(i).group(0); + final recognizer = TapGestureRecognizer()..onTap = () => _launchUrl(link); + _gestureRecognizers.add(recognizer); + textSpans.add( + TextSpan(text: link, style: linkStyle, recognizer: recognizer), + ); + i++; + } + }); + + return RichText(text: TextSpan(children: textSpans), textAlign: widget.textAlign); + } +} diff --git a/flokk_src/lib/_internal/components/seperated_flexibles.dart b/flokk_src/lib/_internal/components/seperated_flexibles.dart new file mode 100644 index 0000000..a842444 --- /dev/null +++ b/flokk_src/lib/_internal/components/seperated_flexibles.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +typedef Widget SeparatorBuilder(); + +class SeparatedRow extends StatelessWidget { + final List children; + final SeparatorBuilder separatorBuilder; + final MainAxisAlignment mainAxisAlignment; + final CrossAxisAlignment crossAxisAlignment; + final MainAxisSize mainAxisSize; + final TextBaseline textBaseline; + final TextDirection textDirection; + final VerticalDirection verticalDirection; + + const SeparatedRow({ + Key key, + this.children, + this.separatorBuilder, + this.mainAxisAlignment = MainAxisAlignment.start, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.mainAxisSize = MainAxisSize.max, + this.verticalDirection = VerticalDirection.down, + this.textBaseline, + this.textDirection, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + List c = children.toList(); + for (var i = c.length; i-- > 0;) { + if (i > 0) c.insert(i, separatorBuilder()); + } + return Row( + children: c, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + mainAxisSize: mainAxisSize, + textBaseline: textBaseline, + textDirection: textDirection, + verticalDirection: verticalDirection, + ); + } +} + +class SeparatedColumn extends StatelessWidget { + final List children; + final SeparatorBuilder separatorBuilder; + final MainAxisAlignment mainAxisAlignment; + final CrossAxisAlignment crossAxisAlignment; + final MainAxisSize mainAxisSize; + final TextBaseline textBaseline; + final TextDirection textDirection; + final VerticalDirection verticalDirection; + + const SeparatedColumn({ + Key key, + this.children, + this.separatorBuilder, + this.mainAxisAlignment = MainAxisAlignment.start, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.mainAxisSize = MainAxisSize.max, + this.verticalDirection = VerticalDirection.down, + this.textBaseline, + this.textDirection, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + List c = children.toList(); + for (var i = c.length; i-- > 0;) { + if (i > 0 && separatorBuilder != null) c.insert(i, separatorBuilder()); + } + return Column( + children: c, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + mainAxisSize: mainAxisSize, + textBaseline: textBaseline, + textDirection: textDirection, + verticalDirection: verticalDirection, + ); + } +} diff --git a/flokk_src/lib/_internal/components/simple_grid.dart b/flokk_src/lib/_internal/components/simple_grid.dart new file mode 100644 index 0000000..42c59a5 --- /dev/null +++ b/flokk_src/lib/_internal/components/simple_grid.dart @@ -0,0 +1,54 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flutter/material.dart'; + +class SimpleGrid extends StatelessWidget { + final double hSpace; + final double vSpace; + final int colCount; + final List kids; + final double kidHeight; + final CrossAxisAlignment align; + + //TODO SB: Refactor this class to support Hz scrolling + const SimpleGrid({ + Key key, + this.hSpace = 0, + this.vSpace = 0, + this.kids, + this.colCount = 2, + this.kidHeight = 40, + this.align = CrossAxisAlignment.start, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + //Build a bunch of columns in a bunch of rows + int rowCount = (kids.length / colCount).ceil(); + List col = []; + //Fill each row with items + int kidIndex = 0; + for (var i = rowCount; i-- > 0;) { + List row = []; + for (var i2 = colCount; i2-- > 0;) { + if (kidIndex <= kids.length - 1) { + row.add(kids[kidIndex].flexible()); + } else { + row.add(Container().flexible()); + } + kidIndex++; + // Fill gaps with hSpace + if (i2 > 0) { + row.add(SizedBox(width: hSpace)); + } + } + col.add(Row(children: row).height(kidHeight)); + // Fill gaps with vSpace + if (i > 0) col.add(SizedBox(height: vSpace)); + } + return Column( + crossAxisAlignment: align, + mainAxisSize: MainAxisSize.min, + children: col, + ); + } +} diff --git a/flokk_src/lib/_internal/components/simple_value_notifier.dart b/flokk_src/lib/_internal/components/simple_value_notifier.dart new file mode 100644 index 0000000..2f34aa3 --- /dev/null +++ b/flokk_src/lib/_internal/components/simple_value_notifier.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +class SimpleNotifier extends ChangeNotifier { + void notify() => notifyListeners(); +} + +class SimpleValueNotifier extends ValueNotifier { + SimpleValueNotifier(T value) : super(value); + + void notify() => notifyListeners(); +} + +class SomeProps with ChangeNotifier { + bool value1 = false; + + void setV1(bool v) { + value1 = v; + notifyListeners(); + } +} diff --git a/flokk_src/lib/_internal/components/spacers.dart b/flokk_src/lib/_internal/components/spacers.dart new file mode 100644 index 0000000..001511a --- /dev/null +++ b/flokk_src/lib/_internal/components/spacers.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class VtSpace extends StatelessWidget { + final double size; + + VtSpace(this.size); + + @override + Widget build(BuildContext context) => SizedBox(height: size); +} + +class HzSpace extends StatelessWidget { + final double size; + + HzSpace(this.size); + + @override + Widget build(BuildContext context) => SizedBox(width: size); +} diff --git a/flokk_src/lib/_internal/components/spacing.dart b/flokk_src/lib/_internal/components/spacing.dart new file mode 100644 index 0000000..02cf7b8 --- /dev/null +++ b/flokk_src/lib/_internal/components/spacing.dart @@ -0,0 +1,29 @@ +import 'package:flutter/cupertino.dart'; + +class Space extends StatelessWidget { + final double width; + final double height; + + Space(this.width, this.height); + + @override + Widget build(BuildContext context) => SizedBox(width: width, height: height); +} + +class VSpace extends StatelessWidget { + final double size; + + const VSpace(this.size, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) => Space(0, size); +} + +class HSpace extends StatelessWidget { + final double size; + + const HSpace(this.size, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) => Space(size, 0); +} diff --git a/flokk_src/lib/_internal/components/translate_and_align.dart b/flokk_src/lib/_internal/components/translate_and_align.dart new file mode 100644 index 0000000..605fae4 --- /dev/null +++ b/flokk_src/lib/_internal/components/translate_and_align.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class TranslateAndAlign extends StatelessWidget { + final Offset offset; + final Alignment align; + final Widget child; + + TranslateAndAlign({this.child, this.offset, this.align}); + + @override + Widget build(BuildContext context) { + return Align( + alignment: align ?? Alignment.topLeft, + child: Transform.translate( + offset: offset ?? Offset.zero, + child: child, + ), + ); + } +} diff --git a/flokk_src/lib/_internal/http_client.dart b/flokk_src/lib/_internal/http_client.dart new file mode 100644 index 0000000..65e0a20 --- /dev/null +++ b/flokk_src/lib/_internal/http_client.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:flokk/_internal/log.dart'; +import 'package:http/http.dart' as http; + +enum NetErrorType { + none, + disconnected, + timedOut, + denied, +} + +typedef Future HttpRequest(); + +class HttpClient { + static Future get(String url, {Map headers}) async { + return await _request(() async { + return await http.get(url, headers: headers); + }); + } + + static Future post(String url, {Map headers, dynamic body, Encoding encoding}) async { + return await _request(() async { + return await http.post(url, headers: headers, body: body, encoding: encoding); + }); + } + + static Future put(String url, {Map headers, dynamic body, Encoding encoding}) async { + return await _request(() async { + return await http.put(url, headers: headers, body: body, encoding: encoding); + }); + } + + static Future patch(String url, {Map headers, dynamic body, Encoding encoding}) async { + return await _request(() async { + return await http.patch(url, headers: headers, body: body, encoding: encoding); + }); + } + + static Future delete(String url, {Map headers}) async { + return await _request(() async { + return await http.delete(url, headers: headers); + }); + } + + static Future head(String url, {Map headers}) async { + return await _request(() async { + return await http.head(url, headers: headers); + }); + } + + static Future _request(HttpRequest request) async { + http.Response response; + try { + response = await request(); + } on Exception catch (e) { + Log.e("Network call failed. error = ${e.toString()}"); + } + return HttpResponse(response); + } +} + +class HttpResponse { + final http.Response raw; + + NetErrorType errorType; + + bool get success => errorType == NetErrorType.none; + + String get body => raw?.body; + + Map get headers => raw?.headers; + + int get statusCode => raw?.statusCode ?? -1; + + HttpResponse(this.raw) { + //No response at all, there must have been a connection error + if (raw == null) + errorType = NetErrorType.disconnected; + //200 means all is good :) + else if (raw.statusCode == 200) + errorType = NetErrorType.none; + //500's, server is probably down + else if (raw.statusCode >= 500 && raw.statusCode < 600) + errorType = NetErrorType.timedOut; + //400's server is denying our request, probably bad auth or malformed request + else if (raw.statusCode >= 400 && raw.statusCode < 500) errorType = NetErrorType.denied; + } +} diff --git a/flokk_src/lib/_internal/log.dart b/flokk_src/lib/_internal/log.dart new file mode 100644 index 0000000..8a22f79 --- /dev/null +++ b/flokk_src/lib/_internal/log.dart @@ -0,0 +1,37 @@ +import 'package:flokk/_internal/universal_file/universal_file.dart'; +import 'package:intl/intl.dart'; + +class Log { + static bool writeToDisk = true; + static UniversalFile _printFile; + static UniversalFile _errorFile; + + static Future init() async { + if (_printFile != null) return; + _printFile = UniversalFile("editor-log.txt"); + _errorFile = UniversalFile("error-log.txt"); + } + + static void p(String value, [bool writeTimestamp = true]) { + init().then((_) { + print(value); + if (writeToDisk) { + _printFile.write(_formatLine(value, writeTimestamp), true); + } + }); + } + + static String _formatLine(String value, bool writeTimestamp) { + String date = writeTimestamp ? "${DateFormat("EEE MMM d @ H:m:s").format(DateTime.now())}: " : null; + return "${date ?? ""}$value \n"; + } + + static void e(String error, {StackTrace stack, bool writeTimestamp = true}) { + init().then((dynamic value) { + print("[ERROR] $error"); + if (writeToDisk) { + _errorFile.write(_formatLine("[ERROR] $value\n${stack?.toString()}", writeTimestamp), true); + } + }); + } +} diff --git a/flokk_src/lib/_internal/page_routes.dart b/flokk_src/lib/_internal/page_routes.dart new file mode 100644 index 0000000..b9fed32 --- /dev/null +++ b/flokk_src/lib/_internal/page_routes.dart @@ -0,0 +1,75 @@ +import 'package:animations/animations.dart'; +import 'package:flutter/material.dart'; + +typedef Widget PageBuilder(); + +class PageRoutes { + static const double kDefaultDuration = .35; + static const Curve kDefaultEaseFwd = Curves.easeOut; + static const Curve kDefaultEaseReverse = Curves.easeOut; + + static Route fade(PageBuilder pageBuilder, [double duration = kDefaultDuration]) { + return PageRouteBuilder( + transitionDuration: Duration(milliseconds: (duration * 1000).round()), + pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ); + } + + static Route fadeThrough(PageBuilder pageBuilder, [double duration = kDefaultDuration]) { + return PageRouteBuilder( + transitionDuration: Duration(milliseconds: (duration * 1000).round()), + pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeThroughTransition(animation: animation, secondaryAnimation: secondaryAnimation, child: child); + }, + ); + } + + static Route fadeScale(PageBuilder pageBuilder, [double duration = kDefaultDuration]) { + return PageRouteBuilder( + transitionDuration: Duration(milliseconds: (duration * 1000).round()), + pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeScaleTransition(animation: animation, child: child); + }, + ); + } + + static Route sharedAxis(PageBuilder pageBuilder, + [SharedAxisTransitionType type = SharedAxisTransitionType.scaled, double duration = kDefaultDuration]) { + return PageRouteBuilder( + transitionDuration: Duration(milliseconds: (duration * 1000).round()), + pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return SharedAxisTransition( + child: child, + animation: animation, + secondaryAnimation: secondaryAnimation, + transitionType: type, + ); + }, + ); + } + + static Route slide(PageBuilder pageBuilder, + {double duration = kDefaultDuration, + Offset startOffset = const Offset(1, 0), + Curve easeFwd = kDefaultEaseFwd, + Curve easeReverse = kDefaultEaseReverse}) { + return PageRouteBuilder( + transitionDuration: Duration(milliseconds: (duration * 1000).round()), + pageBuilder: (context, animation, secondaryAnimation) => pageBuilder(), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + bool reverse = animation.status == AnimationStatus.reverse; + return SlideTransition( + position: Tween(begin: startOffset, end: Offset(0, 0)) + .animate(CurvedAnimation(parent: animation, curve: reverse ? easeReverse : easeFwd)), + child: child, + ); + }, + ); + } +} diff --git a/flokk_src/lib/_internal/pointer_blocker.dart b/flokk_src/lib/_internal/pointer_blocker.dart new file mode 100644 index 0000000..4390bb5 --- /dev/null +++ b/flokk_src/lib/_internal/pointer_blocker.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class MouseAndPointerBlocker extends StatelessWidget { + final Widget child; + final bool isEnabled; + + const MouseAndPointerBlocker({Key key, this.child, this.isEnabled}) : super(key: key); + + @override + Widget build(BuildContext context) { + //if(!isEnabled) return child; + return IgnorePointer( + ignoring: !isEnabled, + child: RawMaterialButton( + padding: EdgeInsets.zero, + onPressed: null, + child: child, + ), + ); + } +} diff --git a/flokk_src/lib/_internal/universal_file/io_file.dart b/flokk_src/lib/_internal/universal_file/io_file.dart new file mode 100644 index 0000000..dfbbfec --- /dev/null +++ b/flokk_src/lib/_internal/universal_file/io_file.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:flokk/_internal/utils/path.dart'; +import 'package:path/path.dart' as p; + +import 'universal_file.dart'; + +class IoFileWriter implements UniversalFile { + Directory dataPath; + + @override + String fileName; + + IoFileWriter(this.fileName); + + String get fullPath => p.join(dataPath.path, fileName); + + Future getDataPath() async { + if (dataPath != null) return; + dataPath = Directory(await PathUtil.dataPath); + if (Platform.isWindows || Platform.isLinux) { + createDirIfNotExists(dataPath); + } + } + + @override + Future read() async { + await getDataPath(); + return await File("$fullPath").readAsString().catchError(print); + } + + @override + Future write(String value, [bool append = false]) async { + await getDataPath(); + await File("$fullPath") + .writeAsString( + value, + mode: append ? FileMode.append : FileMode.write, + ) + .catchError(print); + } + + static void createDirIfNotExists(Directory dir) async { + //Create directory if it doesn't exist + if (dir != null && !await dir.exists()) { + //Catch error since disk io can always fail. + await dir.create(recursive: true).catchError((e, stack) => print(e)); + } + } +} + +UniversalFile getPlatformFileWriter(String string) => IoFileWriter(string); diff --git a/flokk_src/lib/_internal/universal_file/universal_file.dart b/flokk_src/lib/_internal/universal_file/universal_file.dart new file mode 100644 index 0000000..94b91fc --- /dev/null +++ b/flokk_src/lib/_internal/universal_file/universal_file.dart @@ -0,0 +1,14 @@ +//If in web, this class will write a string to the prefs file, using filename as key +//If on desktop or mobile, write to the appData folder + +import 'universal_file_locator.dart' if (dart.library.html) 'web_file.dart' if (dart.library.io) 'io_file.dart'; + +abstract class UniversalFile { + String fileName; + + Future write(String value, [bool append = false]); + + Future read(); + + factory UniversalFile(String fileName) => getPlatformFileWriter(fileName); +} diff --git a/flokk_src/lib/_internal/universal_file/universal_file_locator.dart b/flokk_src/lib/_internal/universal_file/universal_file_locator.dart new file mode 100644 index 0000000..dfa6270 --- /dev/null +++ b/flokk_src/lib/_internal/universal_file/universal_file_locator.dart @@ -0,0 +1,4 @@ +import 'universal_file.dart'; + +UniversalFile getPlatformFileWriter(String fileName) => + throw UnsupportedError('Cannot create a fileWriter for "$fileName" without the packages dart:html or dart:io'); diff --git a/flokk_src/lib/_internal/universal_file/web_file.dart b/flokk_src/lib/_internal/universal_file/web_file.dart new file mode 100644 index 0000000..56c629d --- /dev/null +++ b/flokk_src/lib/_internal/universal_file/web_file.dart @@ -0,0 +1,40 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import 'universal_file.dart'; + +class WebFileWriter implements UniversalFile { + SharedPreferences prefs; + + @override + String fileName; + + String _lastWrite = ""; + + WebFileWriter(this.fileName); + + Future initPrefs() async { + prefs ??= await SharedPreferences.getInstance(); + } + + @override + Future read() async { + await initPrefs(); + String value = prefs.getString(fileName); + //print("Reading pref: $fileName = $value"); + return value; + } + + @override + Future write(String value, [bool append = false]) async { + await initPrefs(); + if (append && _lastWrite == null) { + _lastWrite = await read(); + value = _lastWrite + value; + } + //print("Write: $fileName = $value"); + _lastWrite = value; + await prefs.setString(fileName, value); + } +} + +UniversalFile getPlatformFileWriter(String fileName) => WebFileWriter(fileName); diff --git a/flokk_src/lib/_internal/universal_picker/desktop_picker.dart b/flokk_src/lib/_internal/universal_picker/desktop_picker.dart new file mode 100644 index 0000000..1ea2051 --- /dev/null +++ b/flokk_src/lib/_internal/universal_picker/desktop_picker.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flokk/_internal/utils/picker.dart'; +import 'package:flutter/foundation.dart'; + +import 'universal_picker.dart'; + +class DesktopPicker implements UniversalPicker { + @override + ValueChanged onChange; + + @override + Uint8List byteData; + + @override + String base64Data; + + //accept: filters for files (ie. images, etc), expecting the same format as that found for html input accept https://www.w3schools.com/TAGS/att_input_accept.asp + DesktopPicker({String accept}) { + // The desktop file picker plugin doesn't accept these input accept strings, + // the pickImage function has a hardcoded image filter in it + } + + void _openPicker() async { + final imagePath = await pickImage(confirmText: "Upload image"); + if (imagePath == null) + // The user most likely pressed cancel or we don't have an image for some other reason, return + return; + final bytes = await File(imagePath).readAsBytes(); + byteData = bytes; + base64Data = Base64Encoder().convert(bytes.toList()); + + onChange(base64Data); + } + + @override + void open() { + _openPicker(); + } +} + +UniversalPicker getPlatformPicker({String accept}) => DesktopPicker(accept: accept); diff --git a/flokk_src/lib/_internal/universal_picker/universal_picker.dart b/flokk_src/lib/_internal/universal_picker/universal_picker.dart new file mode 100644 index 0000000..0cbc889 --- /dev/null +++ b/flokk_src/lib/_internal/universal_picker/universal_picker.dart @@ -0,0 +1,21 @@ +//If in web, use the FileUploadInputElement found in dart:html +//If on desktop or mobile, use ... + +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import 'universal_picker_locator.dart' + if (dart.library.html) 'web_picker.dart' + if (dart.library.io) 'desktop_picker.dart'; + +abstract class UniversalPicker { + Uint8List byteData; + String base64Data; + + ValueChanged onChange; + + void open(); + + factory UniversalPicker({String accept}) => getPlatformPicker(accept: accept); +} diff --git a/flokk_src/lib/_internal/universal_picker/universal_picker_locator.dart b/flokk_src/lib/_internal/universal_picker/universal_picker_locator.dart new file mode 100644 index 0000000..52b0dee --- /dev/null +++ b/flokk_src/lib/_internal/universal_picker/universal_picker_locator.dart @@ -0,0 +1,4 @@ +import 'universal_picker.dart'; + +UniversalPicker getPlatformPicker({String accept}) => + throw UnsupportedError('Cannot create a picker without the packages dart:html or whatever is used for desktop'); diff --git a/flokk_src/lib/_internal/universal_picker/web_picker.dart b/flokk_src/lib/_internal/universal_picker/web_picker.dart new file mode 100644 index 0000000..04cab68 --- /dev/null +++ b/flokk_src/lib/_internal/universal_picker/web_picker.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'dart:html'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; + +import 'universal_picker.dart'; + +class WebPicker implements UniversalPicker { + @override + ValueChanged onChange; + + @override + Uint8List byteData; + + @override + String base64Data; + + InputElement uploadInput; + FileReader reader; + + WebPicker({String accept}) { + print("Web Picker Constructor: accept: $accept"); + reader = FileReader(); + reader.onLoad.listen(handleFileLoad); + + uploadInput = FileUploadInputElement(); + uploadInput.accept = accept ?? ""; + uploadInput.draggable = true; + + uploadInput.onChange.listen(handleInputChange); + } + + @override + void open() { + print("Web Picker open"); + uploadInput.click(); + } + + void handleInputChange(Event e) { + if (uploadInput.files?.isNotEmpty ?? false) { + File f = uploadInput.files.first; + reader.readAsDataUrl(f); + } + } + + void handleFileLoad(ProgressEvent e) { + base64Data = reader.result.toString().split(",").last; + byteData = Base64Decoder().convert(base64Data); + onChange(base64Data); + } +} + +UniversalPicker getPlatformPicker({String accept}) => WebPicker(accept: accept); diff --git a/flokk_src/lib/_internal/url_launcher/url_launcher.dart b/flokk_src/lib/_internal/url_launcher/url_launcher.dart new file mode 100644 index 0000000..65e3445 --- /dev/null +++ b/flokk_src/lib/_internal/url_launcher/url_launcher.dart @@ -0,0 +1,41 @@ +import 'url_launcher_locator.dart' + if (dart.library.html) 'url_launcher_web.dart' + if (dart.library.io) 'url_launcher_io.dart'; + +class UrlLauncher { + static Future open(String value) async { + if (value == null) return false; + bool success = await urlLauncherOpen(value); + if (!success) print('Could not launch $value'); + return success; + } + + static Future openHttp(String value) async { + if (value == null) return false; + if (!value.contains("http") && !value.contains("http")) { + value = "http://$value"; + } + bool success = await urlLauncherOpen(value); + if (!success) print('Could not launch $value'); + return success; + } + + static void openPhoneNumber(String value) { + if (value == null) return; + value = RegExp(r"([\d+])").allMatches(value).map((m) => m.group(0)).join(""); + open("https://hangouts.google.com/?action=chat&pn=%2B$value&"); + } + + static void openEmail(String value) { + if (value == null) return; + + /// TODO: Add regEx check, don't bother opening if it's not a valid email + open("mailto:$value"); + } + + static void openGoogleMaps(String value) => open("https://www.google.com/maps/search/${Uri.encodeFull(value)}/"); + + static void openGitUser(String value) => open("https://github.com/$value/"); + + static void openTwitterUser(String value) => open("https://twitter.com/${value.replaceAll("@", "")}/"); +} diff --git a/flokk_src/lib/_internal/url_launcher/url_launcher_io.dart b/flokk_src/lib/_internal/url_launcher/url_launcher_io.dart new file mode 100644 index 0000000..b5bd52f --- /dev/null +++ b/flokk_src/lib/_internal/url_launcher/url_launcher_io.dart @@ -0,0 +1,25 @@ +import "dart:io" as io; +import 'dart:io'; + +import 'package:flokk/_internal/log.dart'; +import 'package:universal_platform/universal_platform.dart'; + +Future urlLauncherOpen(String url) async { + ProcessResult result; + try { + if (UniversalPlatform.isLinux) { + result = await io.Process.run("xdg-open", [ + url, + ]); + } else if (UniversalPlatform.isWindows) { + url = url.replaceAll("&", "^&"); + result = await io.Process.run("start", [url], runInShell: true); + } else if (UniversalPlatform.isMacOS) { + result = await io.Process.run("open", [url]); + } + } on ProcessException catch (e) { + Log.e(e?.message); + } + ; + return result?.exitCode == 0; +} diff --git a/flokk_src/lib/_internal/url_launcher/url_launcher_locator.dart b/flokk_src/lib/_internal/url_launcher/url_launcher_locator.dart new file mode 100644 index 0000000..3b1000a --- /dev/null +++ b/flokk_src/lib/_internal/url_launcher/url_launcher_locator.dart @@ -0,0 +1 @@ +Future urlLauncherOpen(String url) => throw UnsupportedError('Unimplemented Url Opener'); diff --git a/flokk_src/lib/_internal/url_launcher/url_launcher_web.dart b/flokk_src/lib/_internal/url_launcher/url_launcher_web.dart new file mode 100644 index 0000000..626d4e7 --- /dev/null +++ b/flokk_src/lib/_internal/url_launcher/url_launcher_web.dart @@ -0,0 +1,12 @@ +import 'package:url_launcher/url_launcher.dart'; + +Future urlLauncherOpen(String url) async { + try { + if (await canLaunch(url)) { + return await launch(url); + } + } catch (e) { + print(e); + } + return false; +} diff --git a/flokk_src/lib/_internal/utils/build_utils.dart b/flokk_src/lib/_internal/utils/build_utils.dart new file mode 100644 index 0000000..69929c2 --- /dev/null +++ b/flokk_src/lib/_internal/utils/build_utils.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class BuildUtils { + + static void getFutureSizeFromGlobalKey(GlobalKey key, Function(Size size) callback) { + Future.microtask(() { + Size size = getSizeFromContext(key.currentContext); + if (size != null) { + callback(size); + } + }); + } + + static Size getSizeFromContext(BuildContext context) { + RenderBox rb = context.findRenderObject(); + return rb?.size; + } + + static Offset getOffsetFromContext(BuildContext context, [Offset offset = null]) { + RenderBox rb = context.findRenderObject(); + return rb?.localToGlobal(offset ?? Offset.zero); + } + + +} \ No newline at end of file diff --git a/flokk_src/lib/_internal/utils/color_utils.dart b/flokk_src/lib/_internal/utils/color_utils.dart new file mode 100644 index 0000000..6838dbf --- /dev/null +++ b/flokk_src/lib/_internal/utils/color_utils.dart @@ -0,0 +1,21 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; + +class ColorUtils { + static Color shiftHsl(Color c, [double amt = 0]) { + var hslc = HSLColor.fromColor(c); + return hslc.withLightness((hslc.lightness + amt).clamp(0.0, 1.0)).toColor(); + } + + static Color parseHex(String value) => Color(int.parse(value.substring(1, 7), radix: 16) + 0xFF000000); + + static Color blend(Color dst, Color src, double opacity) { + return Color.fromARGB( + 255, + (dst.red.toDouble() * (1.0 - opacity) + src.red.toDouble() * opacity).toInt(), + (dst.green.toDouble() * (1.0 - opacity) + src.green.toDouble() * opacity).toInt(), + (dst.blue.toDouble() * (1.0 - opacity) + src.blue.toDouble() * opacity).toInt(), + ); + } +} diff --git a/flokk_src/lib/_internal/utils/date_utils.dart b/flokk_src/lib/_internal/utils/date_utils.dart new file mode 100644 index 0000000..822e937 --- /dev/null +++ b/flokk_src/lib/_internal/utils/date_utils.dart @@ -0,0 +1,5 @@ +import 'package:intl/intl.dart'; + +class DateFormats { + static DateFormat google = DateFormat.yMd(); +} diff --git a/flokk_src/lib/_internal/utils/path.dart b/flokk_src/lib/_internal/utils/path.dart new file mode 100644 index 0000000..95108c7 --- /dev/null +++ b/flokk_src/lib/_internal/utils/path.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:xdg_directories/xdg_directories.dart' as xdgDirectories; + +class PathUtil { + static Future get dataPath async { + String result; + if (Platform.isLinux) { + result = "${xdgDirectories.dataHome.path}/flokk-contacts"; + } else { + result = (await getApplicationSupportDirectory().catchError(print)).path; + } + return result; + } + + static Future get homePath async { + return "~/"; + } +} diff --git a/flokk_src/lib/_internal/utils/picker.dart b/flokk_src/lib/_internal/utils/picker.dart new file mode 100644 index 0000000..8e2a9df --- /dev/null +++ b/flokk_src/lib/_internal/utils/picker.dart @@ -0,0 +1,27 @@ +import 'package:file_chooser/file_chooser.dart'; + +import 'path.dart'; + +Future pickImage({String confirmText, String initialPath}) async { + confirmText ??= "Pick Image"; + initialPath ??= await PathUtil.dataPath; + + final result = await showOpenPanel( + initialDirectory: initialPath, + allowedFileTypes: [ + FileTypeFilterGroup( + label: "images", + fileExtensions: ["png", "jpg", "jpeg", "gif", "webm"], + ), + ], + allowsMultipleSelection: false, + canSelectDirectories: false, + confirmButtonText: confirmText, + ); + + if (result.canceled || result.paths.isEmpty) { + return null; + } + + return result.paths[0]; +} diff --git a/flokk_src/lib/_internal/utils/rest_utils.dart b/flokk_src/lib/_internal/utils/rest_utils.dart new file mode 100644 index 0000000..96ebba2 --- /dev/null +++ b/flokk_src/lib/_internal/utils/rest_utils.dart @@ -0,0 +1,12 @@ +class RESTUtils { + static String encodeParams(Map params) { + var s = ""; + params.forEach((key, value) { + if (value != null && value != "null") { + var urlEncode = Uri.encodeFull(value); + s += "&$key=$urlEncode"; + } + }); + return s; + } +} diff --git a/flokk_src/lib/_internal/utils/string_utils.dart b/flokk_src/lib/_internal/utils/string_utils.dart new file mode 100644 index 0000000..e6acf1b --- /dev/null +++ b/flokk_src/lib/_internal/utils/string_utils.dart @@ -0,0 +1,41 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class StringUtils { + static bool isEmpty(String s) { + return s == null || s.trim().isEmpty; + } + + static bool isNotEmpty(String s) => !isEmpty(s); + + static bool isEmailValid(String value) { + if (StringUtils.isEmpty(value)) return false; + return RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+").hasMatch(value); + } + + // Here it is! + static Size measure(String text, TextStyle style, {int maxLines: 1, TextDirection direction = TextDirection.ltr}) { + final TextPainter textPainter = + TextPainter(text: TextSpan(text: text, style: style), maxLines: maxLines, textDirection: direction) + ..layout(minWidth: 0, maxWidth: double.infinity); + return textPainter.size; + } + + static double measureLongest(List items, TextStyle style, [maxItems = null]) { + double l = 0; + if (maxItems != null && maxItems < items.length) { + items.length = maxItems; + } + for (var item in items) { + double m = StringUtils.measure(item, style).width; + if (m > l) l = m; + } + return l; + } + + /// Gracefully handles null values, and skips the suffix when null + static String safeGet(String value, [String suffix]) { + return (value ?? "") + (!StringUtils.isEmpty(value) ? suffix ?? "" : ""); + } +} diff --git a/flokk_src/lib/_internal/utils/utils.dart b/flokk_src/lib/_internal/utils/utils.dart new file mode 100644 index 0000000..ba650c9 --- /dev/null +++ b/flokk_src/lib/_internal/utils/utils.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +class Utils { + static void hideKeyboard() { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + + static bool get isMouseConnected => RendererBinding.instance.mouseTracker.mouseIsConnected; + + static void unFocus() { + WidgetsBinding.instance.focusManager.primaryFocus?.unfocus(); + } + + static void benchmark(String name, void Function() test) { + int ms = DateTime.now().millisecondsSinceEpoch; + test(); + print("Benchmark: $name == ${DateTime.now().millisecondsSinceEpoch - ms}ms"); + } +} diff --git a/flokk_src/lib/_internal/widget_view.dart b/flokk_src/lib/_internal/widget_view.dart new file mode 100644 index 0000000..3994178 --- /dev/null +++ b/flokk_src/lib/_internal/widget_view.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/* +class MyWidget extends StatefulWidget { + @override + _MyWidgetController createState() => _MyWidgetController(); +} + +class _MyWidgetController extends State { + @override + Widget build(BuildContext context) => _MyWidgetView(this); +} + +class _MyWidgetView extends WidgetView { + _MyWidgetView(_MyWidgetController state) : super(state); + + @override + Widget build(BuildContext context) { + return Container(); + } +} + */ +abstract class WidgetView extends StatelessWidget { + final T2 state; + + T1 get widget => (state as State).widget as T1; + + const WidgetView(this.state, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context); +} + +abstract class StatelessView extends StatelessWidget { + final T1 widget; + + const StatelessView(this.widget, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context); +} diff --git a/flokk_src/lib/api_keys.dart b/flokk_src/lib/api_keys.dart new file mode 100644 index 0000000..eba269d --- /dev/null +++ b/flokk_src/lib/api_keys.dart @@ -0,0 +1,19 @@ +/* +NOTE: In order to run the the app you must enter at least the Google API keys. +Twitter and Git can be omitted, but the Social features of the app will not work. +Keys should _not_ be committed to a public repo due to per-app API quotas which can quickly be exhausted. +*/ +class ApiKeys { + /// Google + String googleClientId = ""; + String googleClientSecret = ""; + String googleWebClientId = ""; + + /// Twitter + String twitterKey = ""; + String twitterSecret = ""; + + /// GitHub + String githubKey = ""; + String githubSecret = ""; +} diff --git a/flokk_src/lib/app_extensions.dart b/flokk_src/lib/app_extensions.dart new file mode 100644 index 0000000..bf155a6 --- /dev/null +++ b/flokk_src/lib/app_extensions.dart @@ -0,0 +1,5 @@ +export 'package:flokk/_internal/components/animated_panel.dart'; +export 'package:flokk/_internal/components/clickable_extensions.dart'; +export 'package:sized_context/sized_context.dart'; +export 'package:styled_widget/styled_widget.dart'; +export 'package:time/time.dart'; \ No newline at end of file diff --git a/flokk_src/lib/commands/abstract_command.dart b/flokk_src/lib/commands/abstract_command.dart new file mode 100644 index 0000000..8dabdae --- /dev/null +++ b/flokk_src/lib/commands/abstract_command.dart @@ -0,0 +1,90 @@ +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/commands/dialogs/show_service_error_command.dart'; +import 'package:flokk/commands/refresh_auth_tokens_command.dart'; +import 'package:flokk/globals.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/models/auth_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/models/github_model.dart'; +import 'package:flokk/models/twitter_model.dart'; +import 'package:flokk/services/github_rest_service.dart'; +import 'package:flokk/services/google_rest/google_rest_service.dart'; +import 'package:flokk/services/twitter_rest_service.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +abstract class AbstractCommand { + static BuildContext _lastKnownRoot; + + /// Provide all commands access to the global context & navigator + BuildContext context; + + NavigatorState get rootNav => AppGlobals.nav; + + AbstractCommand(BuildContext c) { + /// Get root context + /// If we're passed a context that is known to be root, skip the lookup, it will throw an error otherwise. + context = (c == _lastKnownRoot) ? c : Provider.of(c, listen: false); + _lastKnownRoot = context; + } + + T getProvided() => Provider.of(context, listen: false); + + /// Convenience lookup methods for all commands to share + /// + /// Models + AuthModel get authModel => getProvided(); + + ContactsModel get contactsModel => getProvided(); + + TwitterModel get twitterModel => getProvided(); + + GithubModel get githubModel => getProvided(); + + AppModel get appModel => getProvided(); + + /// Services + GoogleRestService get googleRestService => getProvided(); + + TwitterRestService get twitterService => getProvided(); + + GithubRestService get gitService => getProvided(); +} + +/// ////////////////////////////////////////////////////////////////// +/// MIX-INS +/// ////////////////////////////////////////////////////////////////// + +mixin CancelableCommandMixin on AbstractCommand { + bool isCancelled = false; + + bool cancel() => isCancelled = true; +} + +mixin AuthorizedServiceCommandMixin on AbstractCommand { + bool ignoreErrors = false; + + /// Runs a service that refreshes Auth if needed, and checks for errors on completion + Future executeAuthServiceCmd(Future Function() cmd) async { + /// Bail early if we're offline + if (!appModel.isOnline) { + Dialogs.show(OkCancelDialog( + title: "No Connection", + message: "It appears your device is offline. Please check your connection and try again.", + onOkPressed: () => rootNav.pop(), + )); + } + + /// Refresh token if needed + await RefreshAuthTokensCommand(context).execute(onlyIfExpired: true); + + /// Execute command + HttpResponse r = await cmd(); + + /// Check for errors + if (!ignoreErrors && r != null) { + ShowServiceErrorCommand(context).execute(r); + } + } +} diff --git a/flokk_src/lib/commands/bootstrap_command.dart b/flokk_src/lib/commands/bootstrap_command.dart new file mode 100644 index 0000000..3bbbd7e --- /dev/null +++ b/flokk_src/lib/commands/bootstrap_command.dart @@ -0,0 +1,121 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/utils/path.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/api_keys.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/commands/refresh_auth_tokens_command.dart'; +import 'package:flokk/commands/social/refresh_social_command.dart'; +import 'package:flokk/commands/web_sign_in_command.dart'; +import 'package:flokk/main.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:universal_platform/universal_platform.dart'; +import 'package:window_size/window_size.dart'; + +class BootstrapCommand extends AbstractCommand { + BootstrapCommand(BuildContext context) : super(context); + + Future execute() async { + /// Let the splash view sit for a bit. Mainly for aesthetics and to ensure a smooth intro animation. + await Future.delayed(.5.seconds); + + /// Display startup logs + Log.writeToDisk = false; + Log.p("/##################################################", false); + Log.p("[BootstrapCommand]"); + if (!UniversalPlatform.isWeb) { + Log.p("DataDir is: ${await PathUtil.dataPath}"); + } + Log.p("##################################################", false); + + /// Define default locale + Intl.defaultLocale = 'en_US'; + + /// Set minimal Window size + // TODO: Remove condition once other implementations are available + if (UniversalPlatform.isMacOS) setWindowMinSize(Size(500, 500)); + + /// Handle version upgrades + if (appModel.version != AppModel.kCurrentVersion) { + appModel.upgradeToVersion(AppModel.kCurrentVersion); + } + + /// Load saved data into necessary models + bool errorLoadingData = false; + await authModel.load().catchError((e, s) { + print("[BootstrapCommand] Error loading AuthModel: $s"); + errorLoadingData = true; + }); + await twitterModel.load().catchError((e, s) { + print("[BootstrapCommand] Error loading TwitterModel: $s"); + errorLoadingData = true; + }); + await githubModel.load().catchError((e, s) { + print("[BootstrapCommand] Error loading GithubModel: $s"); + errorLoadingData = true; + }); + await contactsModel.load().catchError((e, s) { + print("[BootstrapCommand] Error loading ContactsModel: $s"); + errorLoadingData = true; + }); + + /// Reset models if there are any errors, or if the app version has been updated + if (errorLoadingData || appModel.version != AppModel.kCurrentVersion) { + authModel.reset(); + twitterModel.reset(); + githubModel.reset(); + contactsModel.reset(); + } + + /// //////////////////////////////////////////////////////////////// + /// Debug: Inject authModel in web dev builds for quicker local testing + /// TODO: Remove before release + bool sideStepLoginFlow = (kDebugMode || kForceWebLogin) && (UniversalPlatform.isWeb || UniversalPlatform.isAndroid); + if (sideStepLoginFlow) { + // Force login on the web by injecting a known refresh token, which we can use to fetch a valid authKey + authModel.googleRefreshToken = + "1//06TVHZgXSuhqfCgYIARAAGAYSNwF-L9IrPuIVzs3JSYt1xzWSXnK8Vx5cUPgYrEN4FouCtOy1j01hosURDlWogymULquE-e1lXm0"; + await RefreshAuthTokensCommand(context).execute(); + await (RefreshContactsCommand(context)..ignoreErrors = true).execute(); + } + + /// //////////////////////////////////////////////////////////////////////// + + /// After we've loaded the models, kickoff an auth-token refresh, our old one is likely expired. + bool signInError = false; + if (authModel.hasAuthKey && !sideStepLoginFlow) { + /// Try and refresh authKey and Contacts. + bool authSuccess; + if (UniversalPlatform.isWeb) { + // On web, perform a silentSignIn to refresh the OAuth token + authSuccess = await WebSignInCommand(context).execute(silentSignIn: true); + } else { + // On desktop, refresh the authToken manually + authSuccess = await RefreshAuthTokensCommand(context).execute(); + // Make a special exemption for a failed auth-refresh, if we have no key at all. Assume this is running in a test mode. + if(!authSuccess && StringUtils.isEmpty(ApiKeys().googleClientId)){ + authSuccess = true; + AppModel.forceIgnoreGoogleApiCalls = true; + } + } + // Load contacts + ServiceResult contactsResult = await (RefreshContactsCommand(context)..ignoreErrors = true).execute(); + if (contactsResult.success) { + await RefreshSocialCommand(context).execute(contactsModel.allContacts); + } + // Check that both the authCall and contactsLoad was successful + signInError = !authSuccess || !contactsResult.success; + } + + Log.p("#########################", false); + Log.p("BootstrapCommand Complete"); + Log.p("#########################", false); + + return !signInError && authModel.hasAuthKey; + } +} diff --git a/flokk_src/lib/commands/check_connection_command.dart b/flokk_src/lib/commands/check_connection_command.dart new file mode 100644 index 0000000..172e403 --- /dev/null +++ b/flokk_src/lib/commands/check_connection_command.dart @@ -0,0 +1,27 @@ +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class CheckConnectionCommand extends AbstractCommand with CancelableCommandMixin { + CheckConnectionCommand(BuildContext context) : super(context); + + /// Checks if we can connect to the internet. + /// If repeat == true, recursively calls itself forever. + Future execute([bool repeat = false, double wait = 10]) async { + if (UniversalPlatform.isWeb) { + appModel.isOnline = true; + } else { + String url = UniversalPlatform.isWeb ? "flokk.app" : "google.com"; + appModel.isOnline = (await HttpClient.head("https://$url")).success; + if (repeat) { + Future.delayed(wait.seconds).then((value) { + if (isCancelled) return; + execute(true); + }); + } + } + return appModel.isOnline; + } +} diff --git a/flokk_src/lib/commands/contacts/delete_contact_command.dart b/flokk_src/lib/commands/contacts/delete_contact_command.dart new file mode 100644 index 0000000..c664003 --- /dev/null +++ b/flokk_src/lib/commands/contacts/delete_contact_command.dart @@ -0,0 +1,52 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/google_rest/google_rest_contacts_service.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flutter/cupertino.dart'; + +class DeleteContactCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + DeleteContactCommand(BuildContext c) : super(c); + + Future execute(List contacts, {Function() onDeleteConfirmed}) async { + if (contacts == null || contacts.isEmpty || AppModel.forceIgnoreGoogleApiCalls) return false; + Log.p("[DeleteContactCommand]"); + String txt = contacts.length > 1 ? "these ${contacts.length} contacts" : "this contact"; + bool doDelete = await Dialogs.show( + OkCancelDialog( + message: "Are you sure you want to delete $txt?", + okLabel: "Yes", + cancelLabel: "No", + onOkPressed: () => rootNav.pop(true), + onCancelPressed: () => rootNav.pop(false), + ), + ); + if (!doDelete) return false; + onDeleteConfirmed?.call(); + + GoogleRestContactsService service = googleRestService.contacts; + ServiceResult result; + await executeAuthServiceCmd(() async { + /// Update local data optimistically + for (var c in contacts) { + contactsModel.removeContact(c); + } + + /// Create a list of futures + List> futures = + contacts.map((c) => service.delete(authModel.googleAccessToken, c)).toList(); + // Dispatch them all at once + List results = await Future.wait(futures); + //Request succeeded? + result = results[0]; + if (result.success) { + RefreshContactsCommand(context).execute(); + } + return result.response; + }); + return result.success; + } +} diff --git a/flokk_src/lib/commands/contacts/delete_pic_command.dart b/flokk_src/lib/commands/contacts/delete_pic_command.dart new file mode 100644 index 0000000..4b0e8ff --- /dev/null +++ b/flokk_src/lib/commands/contacts/delete_pic_command.dart @@ -0,0 +1,42 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class DeletePicCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + DeletePicCommand(BuildContext c) : super(c); + + Future execute(ContactData contact) async { + if (contact == null || AppModel.forceIgnoreGoogleApiCalls) return false; + Log.p("[DeletePicCommand]"); + + bool doDelete = await Dialogs.show( + OkCancelDialog( + message: "Are you sure you want to delete profile pic?", + okLabel: "Yes", + cancelLabel: "No", + onOkPressed: () => rootNav.pop(true), + onCancelPressed: () => rootNav.pop(false), + ), + ); + if (!doDelete) return false; + + //TODO: replace the profile pic + //Update local data optimistically + ServiceResult result; + await executeAuthServiceCmd(() async { + //Update remove database + result = await googleRestService.contacts.deletePic(authModel.googleAccessToken, contact); + //Request succeeded? + if (result.success) { + RefreshContactsCommand(context).execute(); + } + return result.response; + }); + return result?.success; + } +} diff --git a/flokk_src/lib/commands/contacts/refresh_contacts_command.dart b/flokk_src/lib/commands/contacts/refresh_contacts_command.dart new file mode 100644 index 0000000..eeedf56 --- /dev/null +++ b/flokk_src/lib/commands/contacts/refresh_contacts_command.dart @@ -0,0 +1,49 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/groups/refresh_contact_groups_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/services/google_rest/google_rest_contacts_service.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class RefreshContactsCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + RefreshContactsCommand(BuildContext c) : super(c); + + Future execute({bool skipGroups = false}) async { + Log.p("[RefreshContactsCommand]"); + + ServiceResult result; + await executeAuthServiceCmd(() async { + // Check if we have a sync token... + String syncToken = authModel.googleSyncToken ?? ""; + if(contactsModel.allContacts.isEmpty){ + syncToken = null; + } + result = await googleRestService.contacts.getAll(authModel.googleAccessToken, syncToken); + // Now do we have a sync token? + syncToken = result.content.syncToken ?? ""; + List contacts = result.content.contacts ?? []; + if (result.success) { + authModel.googleSyncToken = syncToken; + //Iterate through returned contacts and either update existing contact or append + for (ContactData n in contacts) { + if (contactsModel.allContacts.any((x) => x.id == n.id)) { + contactsModel.swapContactById(n); + } else { + contactsModel.addContact(n); + } + } + contactsModel.allContacts.removeWhere((ContactData c) => c.isDeleted); + contactsModel.notify(); + contactsModel.scheduleSave(); + } + //Update the groups? + if (!skipGroups) { + await RefreshContactGroupsCommand(context).execute(); + } + Log.p("Contacts loaded = ${contacts?.length ?? 0}"); + return result.response; + }); + return result; + } +} diff --git a/flokk_src/lib/commands/contacts/toggle_favorite_command.dart b/flokk_src/lib/commands/contacts/toggle_favorite_command.dart new file mode 100644 index 0000000..8516812 --- /dev/null +++ b/flokk_src/lib/commands/contacts/toggle_favorite_command.dart @@ -0,0 +1,39 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/groups/refresh_contact_groups_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/google_rest/google_rest_service.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class ToggleFavoriteCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + ToggleFavoriteCommand(BuildContext c) : super(c); + + Future execute(ContactData contact) async { + if (contact == null) return null; + Log.p("[ToggleFavoriteCommand]"); + ServiceResult result; + await executeAuthServiceCmd(() async { + GroupData group = contactsModel.allGroups?.firstWhere( + (x) => x.id == GoogleRestService.kStarredGroupId, + orElse: () => GroupData()..id = GoogleRestService.kStarredGroupId, + ); + // Toggle the contact optimistically + contact.isStarred = !contact.isStarred; + contactsModel.notify(); + + if (contact.isStarred) { + //add to favorites group + result = await googleRestService.groups.modify(authModel.googleAccessToken, group, addContacts: [contact]); + } else { + //remove from favorites group + result = await googleRestService.groups.modify(authModel.googleAccessToken, group, removeContacts: [contact]); + } + // Dispatch background refresh command to make sure we're in sync + RefreshContactGroupsCommand(context).execute(onlyStarred: true); + return result.response; + }); + return result.success; + } +} diff --git a/flokk_src/lib/commands/contacts/update_contact_command.dart b/flokk_src/lib/commands/contacts/update_contact_command.dart new file mode 100644 index 0000000..5ca593b --- /dev/null +++ b/flokk_src/lib/commands/contacts/update_contact_command.dart @@ -0,0 +1,72 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/commands/social/refresh_social_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class UpdateContactCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + UpdateContactCommand(BuildContext c) : super(c); + + Future execute(ContactData contact, {bool updateSocial: false, bool tryAgainOnError = true}) async { + if (contact == null || AppModel.forceIgnoreGoogleApiCalls) return null; + Log.p("[UpdateContactCommand]"); + + ServiceResult result; + await executeAuthServiceCmd(() async { + if (contact.isNew) { + /// Update remote database + result = await googleRestService.contacts.create(authModel.googleAccessToken, contact); + if (result.success) { + result.content.isRecentlyAdded = true; + contactsModel.addContact(result.content); + } + } else { + // Check whether git or twitter changed, if they did we want to reset their cooldowns + ContactData oldContact = contactsModel.getContactById(contact.id); + bool gitChanged = oldContact.gitUsername != contact.gitUsername; + if (gitChanged) { + githubModel.removeEvents(oldContact.gitUsername); + githubModel.scheduleSave(); + contactsModel.clearGitCooldown(contact); + updateSocial = contact.hasGit; + } + bool twitterChanged = oldContact.twitterHandle != contact.twitterHandle; + if (twitterChanged) { + twitterModel.removeTweets(oldContact.twitterHandle); + twitterModel.scheduleSave(); + contactsModel.clearTwitterCooldown(contact); + updateSocial = contact.hasTwitter; + } + + /// Update local data optimistically + contactsModel.swapContactById(contact); + + //Attempt to fetch social (does nothing if no social handles available) + if (updateSocial) RefreshSocialCommand(context).execute([contact]); + + /// Update remote database + result = await googleRestService.contacts.set(authModel.googleAccessToken, contact); + + /// Since we get back the updated object, we can inject it straight into the model to keep us in sync + if (result.success) { + contactsModel.swapContactById(result.content); + } else if (tryAgainOnError && + result.response.statusCode == 400 && + result.response.body.contains("person.etag")) { + //ignore the error to stop error popup + ignoreErrors = true; + await RefreshContactsCommand(context).execute(skipGroups: true); + //try again with updated etag + contact.etag = contactsModel.getContactById(contact.id).etag; + execute(contact, tryAgainOnError: false); + } + } + + return result.response; + }); + return result.success ? result.content : null; + } +} diff --git a/flokk_src/lib/commands/contacts/update_pic_command.dart b/flokk_src/lib/commands/contacts/update_pic_command.dart new file mode 100644 index 0000000..d5f2b85 --- /dev/null +++ b/flokk_src/lib/commands/contacts/update_pic_command.dart @@ -0,0 +1,28 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class UpdatePicCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + UpdatePicCommand(BuildContext c) : super(c); + + Future execute(ContactData contact, String base64Pic) async { + if (contact == null || AppModel.forceIgnoreGoogleApiCalls) return false; + Log.p("[UpdatePicCommand]"); + + ServiceResult result; + await executeAuthServiceCmd(() async { + result = await googleRestService.contacts.updatePic(authModel.googleAccessToken, contact, base64Pic); + if (result.success) { + contact.profilePic = result.content.profilePic; + contact.isDefaultPic = result.content.isDefaultPic; + await RefreshContactsCommand(context).execute(); + } + return result.response; + }); + return result.success; + } +} diff --git a/flokk_src/lib/commands/dialogs/show_discard_warning_command.dart b/flokk_src/lib/commands/dialogs/show_discard_warning_command.dart new file mode 100644 index 0000000..c1ef701 --- /dev/null +++ b/flokk_src/lib/commands/dialogs/show_discard_warning_command.dart @@ -0,0 +1,21 @@ +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class ShowDiscardWarningCommand extends AbstractCommand { + ShowDiscardWarningCommand(BuildContext c) : super(c); + + Future execute() async { + if (appModel.selectedContact == null) return true; + bool isNew = appModel.selectedContact.isNew; + return await Dialogs.show(OkCancelDialog( + okLabel: "DISCARD", + title: "UNSAVED CHANGES FOR ${isNew ? "NEW " : ""}CONTACT", + message: "You have unsaved changes which will be lost if you navigate away.\n" + "Are you sure you wish to discard these changes?", + onOkPressed: () => rootNav.pop(true), + onCancelPressed: () => rootNav.pop(false), + )); + } +} diff --git a/flokk_src/lib/commands/dialogs/show_service_error_command.dart b/flokk_src/lib/commands/dialogs/show_service_error_command.dart new file mode 100644 index 0000000..9ec1cd0 --- /dev/null +++ b/flokk_src/lib/commands/dialogs/show_service_error_command.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/check_connection_command.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class ShowServiceErrorCommand extends AbstractCommand { + ShowServiceErrorCommand(BuildContext context) : super(context); + + static bool isShowingError = false; + + Future execute(HttpResponse response, {String customMessage}) async { + //If response has no errors, return false to indicate no error was shown + if (response.success) return false; + Log.p("[ShowServiceErrorCommand]"); + + String msg; + if (StringUtils.isNotEmpty(customMessage)) { + msg = customMessage; + } else { + msg = + "An unknown error occured (${response.statusCode}). Try checking your internet connection or re-starting the application."; + // 400 Errors (denied request) + if (response.errorType == NetErrorType.denied) { + //Default message + if (response.statusCode == 401) { + msg = + "Something went wrong with authorization, you should probably ${UniversalPlatform.isWeb ? "refresh" : "restart"} the app"; + } else { + //Use message from server if available + Map json = jsonDecode(response.body)["error"]; + if (json?.containsKey("message") ?? false) { + msg = json["message"]; + } else { + msg = "Unable to connect to online services: Internal Server Error (${response.statusCode})"; + } + } + } + // 500 Errors (server) + else if (response.errorType == NetErrorType.timedOut) { + //Default message + msg = "Server is down, please try again later"; + + //Show message from server if available + Map json = jsonDecode(response.body)["error"]; + if (json?.containsKey("message") ?? false) { + msg = json["message"]; + } + } + // No Connection + else if (response.errorType == NetErrorType.disconnected) { + msg = "Unable to connect to the internet, please check your connection."; + //Run an immediate connection check, it's likely that we've lost connection but we're not sure. + await CheckConnectionCommand(context).execute(false); + } + } + + //Suppress popups, only log for release + if (kReleaseMode) { + Log.p(msg); + } + + //Show error popup, if we're not already. Last thing we want to do is spam error messages. + if (!isShowingError && kDebugMode) { + isShowingError = true; + await Dialogs.show( + OkCancelDialog( + title: "Connection Error", + message: msg, + onOkPressed: () => rootNav.pop(), + ), + ); + isShowingError = false; + } + return true; + } +} diff --git a/flokk_src/lib/commands/groups/add_label_to_contact_command.dart b/flokk_src/lib/commands/groups/add_label_to_contact_command.dart new file mode 100644 index 0000000..0c3f40a --- /dev/null +++ b/flokk_src/lib/commands/groups/add_label_to_contact_command.dart @@ -0,0 +1,32 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/groups/create_label_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/cupertino.dart'; + +class AddLabelToContactCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + AddLabelToContactCommand(BuildContext c) : super(c); + + Future> execute(List contacts, {GroupData existingGroup, String newLabel}) async { + Log.p("[AddLabelToContactCommand]"); + ServiceResult result; + await executeAuthServiceCmd(() async { + GroupData group; + if (!StringUtils.isEmpty(newLabel)) { + //create a new label + group = await CreateLabelCommand(context).execute(newLabel); + } else if (existingGroup != null) { + //use existing label + group = existingGroup; + } + if (group != null) { + result = await googleRestService.groups.modify(authModel.googleAccessToken, group, addContacts: contacts); + } + return result?.response; + }); + return contacts; + } +} diff --git a/flokk_src/lib/commands/groups/create_label_command.dart b/flokk_src/lib/commands/groups/create_label_command.dart new file mode 100644 index 0000000..18fe298 --- /dev/null +++ b/flokk_src/lib/commands/groups/create_label_command.dart @@ -0,0 +1,25 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class CreateLabelCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + CreateLabelCommand(BuildContext c) : super(c); + + Future execute(String labelName) async { + Log.p("[CreateLabelCommand]"); + GroupData newGroup = GroupData()..name = labelName; + ServiceResult result; + await executeAuthServiceCmd(() async { + result = await googleRestService.groups.create(authModel.googleAccessToken, newGroup); + newGroup = result.content; + + if (result.success) { + contactsModel.allGroups.add(newGroup); + } + return result.response; + }); + return result.success ? newGroup : null; + } +} diff --git a/flokk_src/lib/commands/groups/delete_label_command.dart b/flokk_src/lib/commands/groups/delete_label_command.dart new file mode 100644 index 0000000..de381a3 --- /dev/null +++ b/flokk_src/lib/commands/groups/delete_label_command.dart @@ -0,0 +1,25 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/groups/refresh_contact_groups_command.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/cupertino.dart'; + +class DeleteLabelCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + DeleteLabelCommand(BuildContext c) : super(c); + + Future execute(GroupData group) async { + if (group == null) return false; + Log.p("[DeleteLabelCommand]"); + ServiceResult result; + await executeAuthServiceCmd(() async { + result = await googleRestService.groups.delete(authModel.googleAccessToken, group); + if (result.success) { + //refresh the groups to ensure labels synced + await RefreshContactGroupsCommand(context).execute(forceUpdate: true); + } + return result.response; + }); + return result.success; + } +} diff --git a/flokk_src/lib/commands/groups/refresh_contact_groups_command.dart b/flokk_src/lib/commands/groups/refresh_contact_groups_command.dart new file mode 100644 index 0000000..94dd968 --- /dev/null +++ b/flokk_src/lib/commands/groups/refresh_contact_groups_command.dart @@ -0,0 +1,70 @@ +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/google_rest/google_rest_contact_groups_service.dart'; +import 'package:flokk/services/google_rest/google_rest_service.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:tuple/tuple.dart'; + +class RefreshContactGroupsCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + RefreshContactGroupsCommand(BuildContext c) : super(c); + + Future> execute({bool onlyStarred = false, bool forceUpdate = false}) async { + Log.p("[RefreshContactGroupsCommand]"); + + if (contactsModel.canRefreshContactGroups || forceUpdate || AppModel.ignoreCooldowns) { + contactsModel.lastUpdatedGroups = DateTime.now(); + + await executeAuthServiceCmd(() async { + GoogleRestContactGroupsService groupsApi = googleRestService.groups; + HttpResponse response; + if (onlyStarred) { + ServiceResult result = + await groupsApi.getById(authModel.googleAccessToken, GoogleRestService.kStarredGroupId); + if (result.success) { + GroupData starred = contactsModel.getGroupById(GoogleRestService.kStarredGroupId); + if (starred != null) { + starred.members = result.content.members; + } else { + contactsModel.allGroups.add(starred); + } + } + response = result.response; + } else { + ServiceResult, String>> result = await groupsApi.get(authModel.googleAccessToken); + List groups = result.content.item1; + String nextPageToken = result.content.item2; + + while (nextPageToken != "" && result.success) { + ServiceResult, String>> result = + await groupsApi.get(authModel.googleAccessToken, nextPageToken: nextPageToken); + groups.addAll(result.content.item1); + nextPageToken = result.content.item2; + } + + if (groups != null && result.success) { + //Need to fetch each individual group to get members list + for (int i = 0; i < groups.length; i++) { + if (groups[i].memberCount > 0 || groups[i].id == GoogleRestService.kStarredGroupId) { + ServiceResult groupResult = + await groupsApi.getById(authModel.googleAccessToken, groups[i].id); + if (groupResult.success) { + groups[i].members = groupResult.content.members; + } + } + } + contactsModel.allGroups = groups; + contactsModel.scheduleSave(); + } + response = result.response; + Log.p("Groups loaded = ${groups?.length ?? 0}"); + } + return response; + }); + } + return contactsModel.allGroups; + } +} diff --git a/flokk_src/lib/commands/groups/remove_label_from_contact_command.dart b/flokk_src/lib/commands/groups/remove_label_from_contact_command.dart new file mode 100644 index 0000000..193f64e --- /dev/null +++ b/flokk_src/lib/commands/groups/remove_label_from_contact_command.dart @@ -0,0 +1,26 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/groups/refresh_contact_groups_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/cupertino.dart'; + +class RemoveLabelFromContactCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + RemoveLabelFromContactCommand(BuildContext c) : super(c); + + Future execute(ContactData contact, GroupData group) async { + Log.p("[RemoveLabelFromContactCommand]"); + + ServiceResult result; + await executeAuthServiceCmd(() async { + result = await googleRestService.groups.modify(authModel.googleAccessToken, group, removeContacts: [contact]); + if (result.success) { + //refresh the groups to ensure labels synced + await RefreshContactGroupsCommand(context).execute(forceUpdate: true); + } + return result.response; + }); + return contactsModel.getContactById(contact.id); + } +} diff --git a/flokk_src/lib/commands/groups/rename_label_command.dart b/flokk_src/lib/commands/groups/rename_label_command.dart new file mode 100644 index 0000000..d1084ab --- /dev/null +++ b/flokk_src/lib/commands/groups/rename_label_command.dart @@ -0,0 +1,23 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class RenameLabelCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + RenameLabelCommand(BuildContext c) : super(c); + + Future execute(GroupData group) async { + if (group == null) return null; + Log.p("[RenameLabelCommand]"); + ServiceResult result; + await executeAuthServiceCmd(() async { + result = await googleRestService.groups.set(authModel.googleAccessToken, group); + if (result.success) { + contactsModel.swapGroupById(result.content); + } + return result.response; + }); + return result.success ? result.content : null; + } +} diff --git a/flokk_src/lib/commands/groups/update_contact_labels_command.dart b/flokk_src/lib/commands/groups/update_contact_labels_command.dart new file mode 100644 index 0000000..b75a4db --- /dev/null +++ b/flokk_src/lib/commands/groups/update_contact_labels_command.dart @@ -0,0 +1,39 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/cupertino.dart'; + +class UpdateContactLabelsCommand extends AbstractCommand with AuthorizedServiceCommandMixin { + UpdateContactLabelsCommand(BuildContext c) : super(c); + + Future execute(ContactData contact) async { + Log.p("[UpdateContactLabelsCommand]"); + ServiceResult result; + await executeAuthServiceCmd(() async { + //Get the existing labels for contact + List existingGroups = contactsModel.getContactById(contact.id)?.groupList ?? []; + + //The updated labels for contact + List updatedGroups = contact.groupList ?? []; + + List removeFrom = existingGroups.where((x) => !updatedGroups.any((y) => y.id == x.id)).toList(); + List addTo = updatedGroups.where((x) => !existingGroups.any((y) => y.id == x.id)).toList(); + + //Remove contact from groups they are no longer in + for (var n in removeFrom) { + result = await googleRestService.groups.modify(authModel.googleAccessToken, n, removeContacts: [contact]); + print("Removed: ${n.name} from ${contact.nameFull}"); + } + + //Add contact to groups they are not in + for (var n in addTo) { + result = await googleRestService.groups.modify(authModel.googleAccessToken, n, addContacts: [contact]); + print("Added: ${n.name} to ${contact.nameFull}"); + } + return result?.response; + }); + return contactsModel.getContactById(contact.id); + } +} diff --git a/flokk_src/lib/commands/logout_command.dart b/flokk_src/lib/commands/logout_command.dart new file mode 100644 index 0000000..f1a445d --- /dev/null +++ b/flokk_src/lib/commands/logout_command.dart @@ -0,0 +1,39 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/page_routes.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/welcome/welcome_page.dart'; +import 'package:flutter/cupertino.dart'; + +class LogoutCommand extends AbstractCommand { + LogoutCommand(BuildContext context) : super(context); + + Future execute({bool doConfirm = false}) async { + Log.p("[LogoutCommand]"); + + if (doConfirm) { + bool doLogout = await Dialogs.show(OkCancelDialog( + title: "Sign Out?", + message: "Are you sure you want to sign-out?", + onOkPressed: () => rootNav.pop(true), + onCancelPressed: () => rootNav.pop(false), + )); + if (!doLogout) return; + } + + //Quietly clear out various models. + // Don't notify listeners, as we don't want the views to clear until we've fully transitioned out + authModel.reset(false); + contactsModel.reset(false); + githubModel.reset(false); + twitterModel.reset(false); + + //Reset the theme and app settings + appModel.theme = ThemeType.FlockGreen; + appModel.reset(false); + + //Show login page + rootNav.pushReplacement(PageRoutes.fade(() => WelcomePage(initialPanelOpen: true))); + } +} diff --git a/flokk_src/lib/commands/refresh_auth_tokens_command.dart b/flokk_src/lib/commands/refresh_auth_tokens_command.dart new file mode 100644 index 0000000..53e4ff1 --- /dev/null +++ b/flokk_src/lib/commands/refresh_auth_tokens_command.dart @@ -0,0 +1,31 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/services/google_rest/google_rest_auth_service.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class RefreshAuthTokensCommand extends AbstractCommand { + RefreshAuthTokensCommand(BuildContext context) : super(context); + + Future execute({bool onlyIfExpired = false}) async { + Log.p("[RefreshAuthTokensCommand] Refreshing with token: ${authModel.googleRefreshToken}"); + if (StringUtils.isEmpty(authModel.googleRefreshToken)) return true; + + //Don't bother calling refresh if it's already authenticated + if (onlyIfExpired && !authModel.isExpired) return true; + + //Query server, see if we can get a new auth token + ServiceResult result = await googleRestService.auth.refresh(authModel.googleRefreshToken); + //If the request succeeded, inject the model with the latest authToken and write to disk + if (result.success) { + authModel.googleAccessToken = result.content.accessToken; + authModel.setExpiry(result.content.expiresIn); + authModel.scheduleSave(); + Log.p( + "Refresh token success. authKey = ${authModel.googleAccessToken}, refreshToken = ${authModel.googleRefreshToken}", + ); + } + return result.success; + } +} diff --git a/flokk_src/lib/commands/social/authenticate_twitter_command.dart b/flokk_src/lib/commands/social/authenticate_twitter_command.dart new file mode 100644 index 0000000..2998935 --- /dev/null +++ b/flokk_src/lib/commands/social/authenticate_twitter_command.dart @@ -0,0 +1,24 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/dialogs/show_service_error_command.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flokk/services/twitter_rest_service.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class AuthenticateTwitterCommand extends AbstractCommand { + AuthenticateTwitterCommand(BuildContext c) : super(c); + + Future execute() async { + Log.p("[AuthenticateTwitterCommand]"); + + ServiceResult result = await twitterService.getAuth(); + if (result.success) { + twitterModel.twitterAccessToken = result.content.accessToken; + twitterModel.scheduleSave(); + return twitterModel.twitterAccessToken; + } else { + ShowServiceErrorCommand(context).execute(result.response); + return ""; + } + } +} diff --git a/flokk_src/lib/commands/social/poll_social_command.dart b/flokk_src/lib/commands/social/poll_social_command.dart new file mode 100644 index 0000000..9d3032f --- /dev/null +++ b/flokk_src/lib/commands/social/poll_social_command.dart @@ -0,0 +1,19 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/social/refresh_social_command.dart'; +import 'package:flutter/src/widgets/framework.dart'; + +class PollSocialCommand extends AbstractCommand with CancelableCommandMixin { + PollSocialCommand(BuildContext c) : super(c); + + Future execute([bool repeat = false, double wait = 15]) async { + await RefreshSocialCommand(context).execute(contactsModel.allContacts); + + if (repeat) { + Future.delayed(wait.minutes).then((value) { + if (isCancelled) return; + execute(true); + }); + } + } +} diff --git a/flokk_src/lib/commands/social/refresh_github_command.dart b/flokk_src/lib/commands/social/refresh_github_command.dart new file mode 100644 index 0000000..65fbac2 --- /dev/null +++ b/flokk_src/lib/commands/social/refresh_github_command.dart @@ -0,0 +1,65 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/dialogs/show_service_error_command.dart'; +import 'package:flokk/data/git_event_data.dart'; +import 'package:flokk/data/git_repo_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/cupertino.dart'; + +class RefreshGithubCommand extends AbstractCommand { + RefreshGithubCommand(BuildContext c) : super(c); + + Future execute(String githubUsername) async { + Log.p("[RefreshGithubCommand]"); + + if (contactsModel.canRefreshGitEventsFor(githubUsername) || AppModel.ignoreCooldowns) { + githubModel.isLoading = true; + ServiceResult eventResult = await gitService.getUserEvents(githubUsername); + + contactsModel.updateSocialTimestamps(gitUsername: githubUsername); + + //set "hasValidGit" flag on contact, depending on success of call + contactsModel.updateContactDataGithubValidity(githubUsername, eventResult.success); + + //Suppress error dialogs if the git username is not found. Already updated the ContactData.hasValidGit flag above + final int statusCode = eventResult.response.statusCode; + switch (statusCode) { + case 403: //rate limit (https://developer.github.com/v3/#rate-limiting) + ShowServiceErrorCommand(context) + .execute(eventResult.response, customMessage: "GitHub rate limit exceeded. Please try again later."); + break; + case 404: //likely invalid git username, don't bother showing error dialog. + break; + default: + ShowServiceErrorCommand(context).execute(eventResult.response); + break; + } + + List events = eventResult?.content ?? []; + githubModel.addEvents(githubUsername, events); + + //Fetch the repos for each event contact was involved in + for (var n in events) { + //The full name of the repo is populated in Event.repo.name, but once Repository is fetched, Repository.fullName will be populated + String fullName = n.event.repo.name; + + if (githubModel.repoIsStale(fullName)) { + ServiceResult repoResult = await gitService.getRepo(fullName); + if (repoResult?.success == true) { + GitRepo repo = repoResult.content; + githubModel.addRepo(repo); + } + } + } + + //Fetch contact owned repos + // ServiceResult> userReposResult = await gitService.getUserRepos(githubUsername); + // if (userReposResult?.success == true) { + // githubModel.addRepos(userReposResult.content); + // } + githubModel.isLoading = false; + githubModel.scheduleSave(); + } + } +} diff --git a/flokk_src/lib/commands/social/refresh_social_command.dart b/flokk_src/lib/commands/social/refresh_social_command.dart new file mode 100644 index 0000000..7a63c8e --- /dev/null +++ b/flokk_src/lib/commands/social/refresh_social_command.dart @@ -0,0 +1,29 @@ +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/social/refresh_github_command.dart'; +import 'package:flokk/commands/social/refresh_twitter_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flutter/material.dart'; + +class RefreshSocialCommand extends AbstractCommand { + RefreshSocialCommand(BuildContext c) : super(c); + + /// Pass in list of contacts to update; can be used to pass in single contact (manual refresh) or else multiple (ie. all contacts on background poll) + Future execute(List contacts) async { + //Ignore if contacts are empty or null + if (contacts?.isEmpty ?? true) { + return; + } + + List githubHandles = + contacts.where((x) => !StringUtils.isEmpty(x.gitUsername))?.map((x) => x.gitUsername)?.toList() ?? []; + List twitterHandles = + contacts.where((x) => !StringUtils.isEmpty(x.twitterHandle))?.map((x) => x.twitterHandle)?.toList() ?? []; + + List> gitFutures = githubHandles.map((x) => RefreshGithubCommand(context).execute(x)).toList(); + await Future.wait(gitFutures); + + List> twitterFutures = twitterHandles.map((x) => RefreshTwitterCommand(context).execute(x)).toList(); + await Future.wait(twitterFutures); + } +} diff --git a/flokk_src/lib/commands/social/refresh_twitter_command.dart b/flokk_src/lib/commands/social/refresh_twitter_command.dart new file mode 100644 index 0000000..e817c61 --- /dev/null +++ b/flokk_src/lib/commands/social/refresh_twitter_command.dart @@ -0,0 +1,50 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flokk/commands/dialogs/show_service_error_command.dart'; +import 'package:flokk/commands/social/authenticate_twitter_command.dart'; +import 'package:flokk/data/tweet_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/cupertino.dart'; + +class RefreshTwitterCommand extends AbstractCommand { + RefreshTwitterCommand(BuildContext c) : super(c); + + Future execute(String twitterHandle) async { + Log.p("[RefreshTwitterCommand]"); + + if (contactsModel.canRefreshTweetsFor(twitterHandle) || AppModel.ignoreCooldowns) { + if (!twitterModel.isAuthenticated) { + await AuthenticateTwitterCommand(context).execute(); + } + twitterModel.isLoading = true; + ServiceResult result = await twitterService.getTweets(twitterModel.twitterAccessToken, twitterHandle); + + contactsModel.updateSocialTimestamps(twitterHandle: twitterHandle); + + //set "hasValidTwitter" flag on contact, depending on success of call + contactsModel.updateContactDataTwitterValidity(twitterHandle, result.success); + + //Suppress error dialogs if the twitter handle is not found. Already updated the ContactData.hasValidTwitter flag above + final int statusCode = result.response.statusCode; + switch (statusCode) { + case 429: //rate limit (https://developer.twitter.com/en/docs/basics/rate-limiting) + ShowServiceErrorCommand(context) + .execute(result.response, customMessage: "Twitter rate limit exceeded. Please try again later."); + break; + case 404: //likely invalid twitter username, don't bother showing error dialog. + break; + default: + ShowServiceErrorCommand(context).execute(result.response); + break; + } + + List tweets = result?.content ?? []; + twitterModel.addTweets(twitterHandle, tweets); + twitterModel.isLoading = false; + twitterModel.scheduleSave(); + int newTweets = contactsModel.getSocialContactByTwitter(twitterHandle).newTweets.length; + print("New Tweets = $newTweets"); + } + } +} diff --git a/flokk_src/lib/commands/social/test_repeat_command.dart b/flokk_src/lib/commands/social/test_repeat_command.dart new file mode 100644 index 0000000..98177ba --- /dev/null +++ b/flokk_src/lib/commands/social/test_repeat_command.dart @@ -0,0 +1,38 @@ +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flutter/cupertino.dart'; + +class TestRepeatCommand extends AbstractCommand { + //needs to be outside the scope of instance otherwise it gets reset and doesn't cancel properly + static bool _isCancelled = false; + + TestRepeatCommand(BuildContext c) : super(c); + + void stop() { + print("stop() called"); + _isCancelled = true; + } + + Future> execute({bool poll = false, Duration pollInterval, bool calledBySelf = false}) async { + pollInterval = pollInterval ?? Duration(seconds: 5); //default interval + + //reset the _isCancelled flag if poll is true and executed by self, allows proper restart of polling + if (poll && !calledBySelf) { + _isCancelled = false; + } + + print("Do stuff: ${DateTime.now()}"); + + if (poll) { + Future.delayed(pollInterval).then((value) { + print("delayed call, isCancelled? $_isCancelled"); + if (_isCancelled) { + print("cancelled"); + return; + } + execute(poll: true, pollInterval: pollInterval, calledBySelf: true); + }); + } + print("return []"); + return []; + } +} diff --git a/flokk_src/lib/commands/web_sign_in_command.dart b/flokk_src/lib/commands/web_sign_in_command.dart new file mode 100644 index 0000000..554d010 --- /dev/null +++ b/flokk_src/lib/commands/web_sign_in_command.dart @@ -0,0 +1,38 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/api_keys.dart'; +import 'package:flokk/commands/abstract_command.dart'; +import 'package:flutter/src/widgets/framework.dart'; +import 'package:google_sign_in/google_sign_in.dart'; + +class WebSignInCommand extends AbstractCommand { + WebSignInCommand(BuildContext c) : super(c); + + Future execute({bool silentSignIn = false}) async { + Log.p("[WebSignInCommand] isSilentSignIn: $silentSignIn"); + try { + final gs = GoogleSignIn( + clientId: ApiKeys().googleWebClientId, + scopes: ['https://www.googleapis.com/auth/contacts'], + ); + + GoogleSignInAccount account = silentSignIn ? await gs.signInSilently() : await gs.signIn(); + GoogleSignInAuthentication auth = await account.authentication; + + if (auth != null) { + Log.p("[WebSignInCommand] Success"); + authModel.googleSignIn = + gs; //save off instance of GoogleSignIn, so it can be used to call googleSignIn.disconnect() if needed + authModel.googleAccessToken = auth.accessToken; + authModel.scheduleSave(); + return true; + } else { + Log.p("[WebSignInCommand] Fail"); + return false; + } + } catch (e) { + print("Error!"); + print(e); + return false; + } + } +} diff --git a/flokk_src/lib/data/contact_data.dart b/flokk_src/lib/data/contact_data.dart new file mode 100644 index 0000000..082fd61 --- /dev/null +++ b/flokk_src/lib/data/contact_data.dart @@ -0,0 +1,377 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/data/social_activity_type.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part "contact_data.g.dart"; + +enum ContactOrderBy { + Email, + FirstName, + LastName, +} + +@JsonSerializable(explicitToJson: true) +class ContactData { + bool get isNew => StringUtils.isEmpty(id); + + String get id => googleId; + + /// //////////////////////////////////////// + + String get firstPhone { + if (phoneList?.isNotEmpty ?? false) { + return phoneList.first.number; + } + return ""; + } + + String get firstEmail { + if (emailList?.isNotEmpty ?? false) { + return emailList.first.value; + } + return ""; + } + + // Indicates that this contact came back in the the last refresh, UI can use this to animate new contacts + bool isRecentlyAdded = false; + bool isDeleted = false; + String googleId = ""; + String etag = ""; + + //Social + String twitterHandle = ""; + String gitUsername = ""; + + //Name info + String generatedTitle = ""; + String nameFull = ""; + String namePrefix = ""; + String nameSuffix = ""; + String nameFamily = ""; + String nameGiven = ""; + String nameGivenPhonetic = ""; + String nameMiddle = ""; + String nameMiddlePhonetic = ""; + + //Misc( + String notes = ""; + BirthdayData birthday = BirthdayData(); + String nickname = ""; + String fileAs = ""; + bool isStarred = false; + + //Job + String jobTitle = ""; + String jobDepartment = ""; + String jobCompany = ""; + + String get formattedJob { + bool hasTitle = !StringUtils.isEmpty(jobTitle); + bool hasCompany = !StringUtils.isEmpty(jobCompany); + if (hasTitle && hasCompany) return "$jobTitle, $jobCompany"; + return hasTitle ? jobTitle : jobCompany ?? ""; + } + + //Profile + String profilePic = ""; + bool isDefaultPic = true; + @JsonKey(ignore: true) + String profilePicBase64 = ""; //base 64 encoded bytes of profile pic (from picker) + @JsonKey(ignore: true) + Uint8List profilePicBytes = null; //raw bytes of profile pic (from picker) + @JsonKey(ignore: true) + bool hasNewProfilePic = false; + + //Phone + List phoneList = []; + + //Email + List emailList = []; + + //Address + List addressList = []; + + //Instant Messaging + List imList = []; + + //User fields + Map customFields = {}; + + //Web Sites + List websiteList = []; + + //Relations + List relationList = []; + + //Labels + @JsonKey(ignore: true) + List groupList = []; + + //Events + List eventList = []; + + ContactData(); + + factory ContactData.fromJson(Map json) => _$ContactDataFromJson(json); + + bool get hasName => !StringUtils.isEmpty("$nameGiven$nameMiddle$nameFamily$nameSuffix$namePrefix"); + + bool get hasLabel => groupList?.isNotEmpty; + + bool get hasEmail => emailList?.isNotEmpty; + + bool get hasPhone => phoneList?.isNotEmpty; + + bool get hasAddress => addressList?.isNotEmpty; + + bool get hasLink => websiteList?.isNotEmpty; + + bool get hasRelationship => relationList?.isNotEmpty; + + bool get hasEvents => eventList?.isNotEmpty; + + bool get hasJob => !StringUtils.isEmpty("$jobTitle$jobCompany$jobDepartment"); + + bool get hasNotes => notes?.isNotEmpty; + + bool get hasBirthday => !StringUtils.isEmpty(birthday?.text); + + bool get hasValidDateForBirthday => birthday.date != DateTime(0, 1, 1); + + /// ///////////////////////////////////////////////////// + /// SOCIAL + bool get hasTwitter => !StringUtils.isEmpty(twitterHandle); + + bool get hasGit => !StringUtils.isEmpty(gitUsername); + + bool get hasAllSocial => hasGit && hasTwitter; + + bool get hasAnySocial => hasGit || hasTwitter; + + bool hasSameSocial(ContactData other) => other.twitterHandle == twitterHandle && other.gitUsername == gitUsername; + + bool hasSocialOfType(SocialActivityType type) { + if (type == SocialActivityType.Git) return hasGit; + if (type == SocialActivityType.Twitter) return hasTwitter; + return false; + } + + //Not serialized, to be set/determined with each fetch because it's possible their account was deleted/changed in the meanwhile + @JsonKey(ignore: true) + bool hasValidGit = false; + @JsonKey(ignore: true) + bool hasValidTwitter = false; + + /// //////////////////////////////////////////////// + /// SEARCHABLE + String _searchable; + + String get searchable => _searchable ??= _getSearchableFields().toLowerCase(); + + String _getSearchableFields() => "$nameGiven $nameMiddle $nameFamily $nameMiddlePhonetic $nameGivenPhonetic " + "$namePrefix $nameSuffix $nameFull $twitterHandle $gitUsername $notes $birthday $nickname" + "$jobTitle $jobDepartment $jobCompany ${phoneList?.map((x) => x.number)?.join(",") ?? ""}" + "${addressList?.map((x) => x.getFullAddress())?.join(",") ?? ""}" + "${imList?.map((x) => x.username)?.join(",") ?? ""}" + "${customFields?.values?.map((x) => x)?.join(",") ?? ""}" + "${relationList?.map((x) => x.person)?.join(",") ?? ""}" + "${emailList?.map((x) => x.value)?.join(",") ?? ""} ${groupList?.map((x) => x.name)?.join(",") ?? ""}"; + + /// //////////////////////////////////////////////// + /// PUBLIC API + ContactData trimLists() { + emailList.removeWhere((email) => email.isEmpty); + phoneList.removeWhere((phone) => phone.isEmpty); + addressList.removeWhere((address) => address.isEmpty); + + websiteList.removeWhere((email) => email.isEmpty); + relationList.removeWhere((rel) => rel.isEmpty); + eventList.removeWhere((evt) => evt.isEmpty); + + return this; + } + + List get allDates { + //Need to explicitly cast x as DateMixin, otherwise will throw CastError when trying to add birthday + List dates = hasEvents ? eventList.map((x) => x as DateMixin).toList() : []; + if (hasValidDateForBirthday) { + dates.add(birthday); + } + return dates; + } + + ContactData copy() => ContactData.fromJson(toJson())..groupList = groupList; + + bool equals(ContactData value) { + if (value == null) return false; + return jsonEncode(value.toJson()).hashCode == jsonEncode(toJson()).hashCode; + } + + Map toJson() => _$ContactDataToJson(this); +} + +@JsonSerializable() +class AddressData { + String formattedAddress = ""; + String street = ""; + String poBox = ""; + String neighborhood = ""; + String city = ""; + String region = ""; + String postcode = ""; + String country = ""; + String type = ""; + + AddressData(); + + get isEmpty => StringUtils.isEmpty("$street$poBox$neighborhood$city$region$postcode$country$type"); + + factory AddressData.fromJson(Map json) => _$AddressDataFromJson(json); + + Map toJson() => _$AddressDataToJson(this); + + String getFullAddress() { + String ss(String value, [String extra]) => StringUtils.safeGet(value, extra); + + String streetAddress = "${ss(street, ", ")}${ss(formattedAddress)}"; + String address = "${ss(streetAddress, " \n")}"; + + String cityRegion = ("${ss(city, ", ")}${ss(region)}"); + address += ss(cityRegion, " \n"); + + String postCountry = "${ss(postcode, ", ")}${ss(country)}"; + address += ss(postCountry, " \n"); + + /// Trim trailing line-break + if (address.length > 1) { + address = address.substring(0, address.length - 1); + } + return address; + } + + String get singleLineStreet { + return street.replaceAll("\n", " "); //strip out \n chars for use in inputs + } +} + +@JsonSerializable() +class InstantMessageData { + String username = ""; + String type = ""; + + InstantMessageData(); + + get isEmpty => StringUtils.isEmpty("$username$type"); + + factory InstantMessageData.fromJson(Map json) => _$InstantMessageDataFromJson(json); + + Map toJson() => _$InstantMessageDataToJson(this); +} + +@JsonSerializable() +class PhoneData { + String uri = ""; + String number = ""; + String type = ""; + + PhoneData(); + + get isEmpty => StringUtils.isEmpty("$number$type"); + + factory PhoneData.fromJson(Map json) => _$PhoneDataFromJson(json); + + Map toJson() => _$PhoneDataToJson(this); +} + +@JsonSerializable() +class WebsiteData { + String href = ""; + String type = ""; + + WebsiteData(); + + get isEmpty => StringUtils.isEmpty("$href$type"); + + factory WebsiteData.fromJson(Map json) => _$WebsiteDataFromJson(json); + + Map toJson() => _$WebsiteDataToJson(this); +} + +@JsonSerializable() +class EmailData { + String value = ""; + String type = ""; + + EmailData(); + + get isEmpty => StringUtils.isEmpty("$value$type"); + + factory EmailData.fromJson(Map json) => _$EmailDataFromJson(json); + + Map toJson() => _$EmailDataToJson(this); +} + +@JsonSerializable() +class RelationData { + String person = ""; + String type = ""; + + RelationData(); + + get isEmpty => StringUtils.isEmpty("$person$type"); + + factory RelationData.fromJson(Map json) => _$RelationDataFromJson(json); + + Map toJson() => _$RelationDataToJson(this); +} + +@JsonSerializable() +class EventData with DateMixin { + String type = ""; + + EventData(); + + @override + String getType() => type; + + get isEmpty => date == DateTime(0, 1, 1) || date == null || date.toString().isEmpty; + + factory EventData.fromJson(Map json) => _$EventDataFromJson(json); + + Map toJson() => _$EventDataToJson(this); +} + +@JsonSerializable() +class BirthdayData with DateMixin { + String text = ""; + + BirthdayData(); + + @override + String getType() => "Birthday"; + + get isEmpty => StringUtils.isEmpty("$text"); + + factory BirthdayData.fromJson(Map json) => _$BirthdayDataFromJson(json); + + Map toJson() => _$BirthdayDataToJson(this); +} + +abstract class DateMixin { + DateTime date = DateTime(0, 1, 1); + + String getType() => ""; + + int get daysTilAnniversary { + final now = DateTime.now(); + final currentYearXDate = DateTime(now.year, date.month, date.day); + final nextYearXDate = DateTime(now.year + 1, date.month, date.day); + return currentYearXDate.isAfter(now) + ? currentYearXDate.difference(now).inDays + : nextYearXDate.difference(now).inDays; + } +} diff --git a/flokk_src/lib/data/contact_data.g.dart b/flokk_src/lib/data/contact_data.g.dart new file mode 100644 index 0000000..456e5e5 --- /dev/null +++ b/flokk_src/lib/data/contact_data.g.dart @@ -0,0 +1,218 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'contact_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ContactData _$ContactDataFromJson(Map json) { + return ContactData() + ..isRecentlyAdded = json['isRecentlyAdded'] as bool + ..isDeleted = json['isDeleted'] as bool + ..googleId = json['googleId'] as String + ..etag = json['etag'] as String + ..twitterHandle = json['twitterHandle'] as String + ..gitUsername = json['gitUsername'] as String + ..generatedTitle = json['generatedTitle'] as String + ..nameFull = json['nameFull'] as String + ..namePrefix = json['namePrefix'] as String + ..nameSuffix = json['nameSuffix'] as String + ..nameFamily = json['nameFamily'] as String + ..nameGiven = json['nameGiven'] as String + ..nameGivenPhonetic = json['nameGivenPhonetic'] as String + ..nameMiddle = json['nameMiddle'] as String + ..nameMiddlePhonetic = json['nameMiddlePhonetic'] as String + ..notes = json['notes'] as String + ..birthday = json['birthday'] == null + ? null + : BirthdayData.fromJson(json['birthday'] as Map) + ..nickname = json['nickname'] as String + ..fileAs = json['fileAs'] as String + ..isStarred = json['isStarred'] as bool + ..jobTitle = json['jobTitle'] as String + ..jobDepartment = json['jobDepartment'] as String + ..jobCompany = json['jobCompany'] as String + ..profilePic = json['profilePic'] as String + ..isDefaultPic = json['isDefaultPic'] as bool + ..phoneList = (json['phoneList'] as List) + ?.map((e) => + e == null ? null : PhoneData.fromJson(e as Map)) + ?.toList() + ..emailList = (json['emailList'] as List) + ?.map((e) => + e == null ? null : EmailData.fromJson(e as Map)) + ?.toList() + ..addressList = (json['addressList'] as List) + ?.map((e) => + e == null ? null : AddressData.fromJson(e as Map)) + ?.toList() + ..imList = (json['imList'] as List) + ?.map((e) => e == null + ? null + : InstantMessageData.fromJson(e as Map)) + ?.toList() + ..customFields = (json['customFields'] as Map)?.map( + (k, e) => MapEntry(k, e as String), + ) + ..websiteList = (json['websiteList'] as List) + ?.map((e) => + e == null ? null : WebsiteData.fromJson(e as Map)) + ?.toList() + ..relationList = (json['relationList'] as List) + ?.map((e) => + e == null ? null : RelationData.fromJson(e as Map)) + ?.toList() + ..eventList = (json['eventList'] as List) + ?.map((e) => + e == null ? null : EventData.fromJson(e as Map)) + ?.toList(); +} + +Map _$ContactDataToJson(ContactData instance) => + { + 'isRecentlyAdded': instance.isRecentlyAdded, + 'isDeleted': instance.isDeleted, + 'googleId': instance.googleId, + 'etag': instance.etag, + 'twitterHandle': instance.twitterHandle, + 'gitUsername': instance.gitUsername, + 'generatedTitle': instance.generatedTitle, + 'nameFull': instance.nameFull, + 'namePrefix': instance.namePrefix, + 'nameSuffix': instance.nameSuffix, + 'nameFamily': instance.nameFamily, + 'nameGiven': instance.nameGiven, + 'nameGivenPhonetic': instance.nameGivenPhonetic, + 'nameMiddle': instance.nameMiddle, + 'nameMiddlePhonetic': instance.nameMiddlePhonetic, + 'notes': instance.notes, + 'birthday': instance.birthday?.toJson(), + 'nickname': instance.nickname, + 'fileAs': instance.fileAs, + 'isStarred': instance.isStarred, + 'jobTitle': instance.jobTitle, + 'jobDepartment': instance.jobDepartment, + 'jobCompany': instance.jobCompany, + 'profilePic': instance.profilePic, + 'isDefaultPic': instance.isDefaultPic, + 'phoneList': instance.phoneList?.map((e) => e?.toJson())?.toList(), + 'emailList': instance.emailList?.map((e) => e?.toJson())?.toList(), + 'addressList': instance.addressList?.map((e) => e?.toJson())?.toList(), + 'imList': instance.imList?.map((e) => e?.toJson())?.toList(), + 'customFields': instance.customFields, + 'websiteList': instance.websiteList?.map((e) => e?.toJson())?.toList(), + 'relationList': instance.relationList?.map((e) => e?.toJson())?.toList(), + 'eventList': instance.eventList?.map((e) => e?.toJson())?.toList(), + }; + +AddressData _$AddressDataFromJson(Map json) { + return AddressData() + ..formattedAddress = json['formattedAddress'] as String + ..street = json['street'] as String + ..poBox = json['poBox'] as String + ..neighborhood = json['neighborhood'] as String + ..city = json['city'] as String + ..region = json['region'] as String + ..postcode = json['postcode'] as String + ..country = json['country'] as String + ..type = json['type'] as String; +} + +Map _$AddressDataToJson(AddressData instance) => + { + 'formattedAddress': instance.formattedAddress, + 'street': instance.street, + 'poBox': instance.poBox, + 'neighborhood': instance.neighborhood, + 'city': instance.city, + 'region': instance.region, + 'postcode': instance.postcode, + 'country': instance.country, + 'type': instance.type, + }; + +InstantMessageData _$InstantMessageDataFromJson(Map json) { + return InstantMessageData() + ..username = json['username'] as String + ..type = json['type'] as String; +} + +Map _$InstantMessageDataToJson(InstantMessageData instance) => + { + 'username': instance.username, + 'type': instance.type, + }; + +PhoneData _$PhoneDataFromJson(Map json) { + return PhoneData() + ..uri = json['uri'] as String + ..number = json['number'] as String + ..type = json['type'] as String; +} + +Map _$PhoneDataToJson(PhoneData instance) => { + 'uri': instance.uri, + 'number': instance.number, + 'type': instance.type, + }; + +WebsiteData _$WebsiteDataFromJson(Map json) { + return WebsiteData() + ..href = json['href'] as String + ..type = json['type'] as String; +} + +Map _$WebsiteDataToJson(WebsiteData instance) => + { + 'href': instance.href, + 'type': instance.type, + }; + +EmailData _$EmailDataFromJson(Map json) { + return EmailData() + ..value = json['value'] as String + ..type = json['type'] as String; +} + +Map _$EmailDataToJson(EmailData instance) => { + 'value': instance.value, + 'type': instance.type, + }; + +RelationData _$RelationDataFromJson(Map json) { + return RelationData() + ..person = json['person'] as String + ..type = json['type'] as String; +} + +Map _$RelationDataToJson(RelationData instance) => + { + 'person': instance.person, + 'type': instance.type, + }; + +EventData _$EventDataFromJson(Map json) { + return EventData() + ..date = + json['date'] == null ? null : DateTime.parse(json['date'] as String) + ..type = json['type'] as String; +} + +Map _$EventDataToJson(EventData instance) => { + 'date': instance.date?.toIso8601String(), + 'type': instance.type, + }; + +BirthdayData _$BirthdayDataFromJson(Map json) { + return BirthdayData() + ..date = + json['date'] == null ? null : DateTime.parse(json['date'] as String) + ..text = json['text'] as String; +} + +Map _$BirthdayDataToJson(BirthdayData instance) => + { + 'date': instance.date?.toIso8601String(), + 'text': instance.text, + }; diff --git a/flokk_src/lib/data/countries.dart b/flokk_src/lib/data/countries.dart new file mode 100644 index 0000000..64f86b4 --- /dev/null +++ b/flokk_src/lib/data/countries.dart @@ -0,0 +1,201 @@ +class Countries { + static List all = [ + "Afghanistan", + "Albania", + "Algeria", + "Andorra", + "Angola", + "Antigua & Deps", + "Argentina", + "Armenia", + "Australia", + "Austria", + "Azerbaijan", + "Bahamas", + "Bahrain", + "Bangladesh", + "Barbados", + "Belarus", + "Belgium", + "Belize", + "Benin", + "Bhutan", + "Bolivia", + "Bosnia Herzegovina", + "Botswana", + "Brazil", + "Brunei", + "Bulgaria", + "Burkina", + "Burundi", + "Cambodia", + "Cameroon", + "Canada", + "Cape Verde", + "Central African Rep", + "Chad", + "Chile", + "China", + "Colombia", + "Comoros", + "Congo", + "Congo {Democratic Rep}", + "Costa Rica", + "Croatia", + "Cuba", + "Cyprus", + "Czech Republic", + "Denmark", + "Djibouti", + "Dominica", + "Dominican Republic", + "East Timor", + "Ecuador", + "Egypt", + "El Salvador", + "Equatorial Guinea", + "Eritrea", + "Estonia", + "Ethiopia", + "Fiji", + "Finland", + "France", + "Gabon", + "Gambia", + "Georgia", + "Germany", + "Ghana", + "Greece", + "Grenada", + "Guatemala", + "Guinea", + "Guinea-Bissau", + "Guyana", + "Haiti", + "Honduras", + "Hungary", + "Iceland", + "India", + "Indonesia", + "Iran", + "Iraq", + "Ireland {Republic}", + "Israel", + "Italy", + "Ivory Coast", + "Jamaica", + "Japan", + "Jordan", + "Kazakhstan", + "Kenya", + "Kiribati", + "Korea North", + "Korea South", + "Kosovo", + "Kuwait", + "Kyrgyzstan", + "Laos", + "Latvia", + "Lebanon", + "Lesotho", + "Liberia", + "Libya", + "Liechtenstein", + "Lithuania", + "Luxembourg", + "Macedonia", + "Madagascar", + "Malawi", + "Malaysia", + "Maldives", + "Mali", + "Malta", + "Marshall Islands", + "Mauritania", + "Mauritius", + "Mexico", + "Micronesia", + "Moldova", + "Monaco", + "Mongolia", + "Montenegro", + "Morocco", + "Mozambique", + "Myanmar", + "{Burma}", + "Namibia", + "Nauru", + "Nepal", + "Netherlands", + "New Zealand", + "Nicaragua", + "Niger", + "Nigeria", + "Norway", + "Oman", + "Pakistan", + "Palau", + "Panama", + "Papua New Guinea", + "Paraguay", + "Peru", + "Philippines", + "Poland", + "Portugal", + "Qatar", + "Romania", + "Russian Federation", + "Rwanda", + "St Kitts & Nevis", + "St Lucia", + "Saint Vincent & the Grenadines", + "Samoa", + "San Marino", + "Sao Tome & Principe", + "Saudi Arabia", + "Senegal", + "Serbia", + "Seychelles", + "Sierra Leone", + "Singapore", + "Slovakia", + "Slovenia", + "Solomon Islands", + "Somalia", + "South Africa", + "South Sudan", + "Spain", + "Sri Lanka", + "Sudan", + "Suriname", + "Swaziland", + "Sweden", + "Switzerland", + "Syria", + "Taiwan", + "Tajikistan", + "Tanzania", + "Thailand", + "Togo", + "Tonga", + "Trinidad & Tobago", + "Tunisia", + "Turkey", + "Turkmenistan", + "Tuvalu", + "Uganda", + "Ukraine", + "United Arab Emirates", + "United Kingdom", + "United States", + "Uruguay", + "Uzbekistan", + "Vanuatu", + "Vatican City", + "Venezuela", + "Vietnam", + "Yemen", + "Zambia", + "Zimbabwe" + ]; +} diff --git a/flokk_src/lib/data/date_sortable_interface.dart b/flokk_src/lib/data/date_sortable_interface.dart new file mode 100644 index 0000000..c99e996 --- /dev/null +++ b/flokk_src/lib/data/date_sortable_interface.dart @@ -0,0 +1,3 @@ +class DateSortable { + DateTime createdAt; +} diff --git a/flokk_src/lib/data/git_event_data.dart b/flokk_src/lib/data/git_event_data.dart new file mode 100644 index 0000000..8336fdf --- /dev/null +++ b/flokk_src/lib/data/git_event_data.dart @@ -0,0 +1,24 @@ +import 'package:flokk/data/date_sortable_interface.dart'; +import 'package:github/github.dart'; + +class GitEvent implements DateSortable { + //Populated at runtime + Repository repository; + + //Serialized to json + Event event; + + @override + DateTime get createdAt => event.createdAt; //Read only + + @override + void set createdAt(DateTime value) {} + + GitEvent(); + + factory GitEvent.fromJson(Map json) { + return GitEvent()..event = json["event"] == null ? null : Event.fromJson(json["event"] as Map); + } + + Map toJson() => {"event": event}; +} diff --git a/flokk_src/lib/data/git_repo_data.dart b/flokk_src/lib/data/git_repo_data.dart new file mode 100644 index 0000000..efd1a4c --- /dev/null +++ b/flokk_src/lib/data/git_repo_data.dart @@ -0,0 +1,22 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:github/github.dart'; + +class GitRepo { + //Populated at runtime + List contacts; + DateTime latestActivityDate; //shown in UI + + //Serialized to json + Repository repository; + DateTime lastUpdated; + + GitRepo(); + + factory GitRepo.fromJson(Map json) { + return GitRepo() + ..repository = json["repository"] == null ? null : Repository.fromJson(json["repository"] as Map) + ..lastUpdated = json["lastUpdated"] == null ? null : DateTime.parse(json['lastUpdated'] as String); + } + + Map toJson() => {"repository": repository, "lastUpdated": lastUpdated?.toIso8601String()}; +} diff --git a/flokk_src/lib/data/group_data.dart b/flokk_src/lib/data/group_data.dart new file mode 100644 index 0000000..2d46a6d --- /dev/null +++ b/flokk_src/lib/data/group_data.dart @@ -0,0 +1,21 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'group_data.g.dart'; + +@JsonSerializable(nullable: true) +class GroupData { + String id; + String etag; //required field in API call for updates + String name; + GroupType groupType; + int memberCount; + List members; + + GroupData(); + + factory GroupData.fromJson(Map json) => _$GroupDataFromJson(json); + + Map toJson() => _$GroupDataToJson(this); +} + +enum GroupType { GroupTypeUnspecified, UserContactGroup, SystemContactGroup } diff --git a/flokk_src/lib/data/group_data.g.dart b/flokk_src/lib/data/group_data.g.dart new file mode 100644 index 0000000..b829e4d --- /dev/null +++ b/flokk_src/lib/data/group_data.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'group_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GroupData _$GroupDataFromJson(Map json) { + return GroupData() + ..id = json['id'] as String + ..etag = json['etag'] as String + ..name = json['name'] as String + ..groupType = _$enumDecodeNullable(_$GroupTypeEnumMap, json['groupType']) + ..memberCount = json['memberCount'] as int + ..members = (json['members'] as List)?.map((e) => e as String)?.toList(); +} + +Map _$GroupDataToJson(GroupData instance) => { + 'id': instance.id, + 'etag': instance.etag, + 'name': instance.name, + 'groupType': _$GroupTypeEnumMap[instance.groupType], + 'memberCount': instance.memberCount, + 'members': instance.members, + }; + +T _$enumDecode( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + throw ArgumentError('A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}'); + } + + final value = enumValues.entries + .singleWhere((e) => e.value == source, orElse: () => null) + ?.key; + + if (value == null && unknownValue == null) { + throw ArgumentError('`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}'); + } + return value ?? unknownValue; +} + +T _$enumDecodeNullable( + Map enumValues, + dynamic source, { + T unknownValue, +}) { + if (source == null) { + return null; + } + return _$enumDecode(enumValues, source, unknownValue: unknownValue); +} + +const _$GroupTypeEnumMap = { + GroupType.GroupTypeUnspecified: 'GroupTypeUnspecified', + GroupType.UserContactGroup: 'UserContactGroup', + GroupType.SystemContactGroup: 'SystemContactGroup', +}; diff --git a/flokk_src/lib/data/social_activity_type.dart b/flokk_src/lib/data/social_activity_type.dart new file mode 100644 index 0000000..48346e2 --- /dev/null +++ b/flokk_src/lib/data/social_activity_type.dart @@ -0,0 +1,5 @@ +enum SocialActivityType { + All, + Git, + Twitter, +} \ No newline at end of file diff --git a/flokk_src/lib/data/social_contact_data.dart b/flokk_src/lib/data/social_contact_data.dart new file mode 100644 index 0000000..e7119e5 --- /dev/null +++ b/flokk_src/lib/data/social_contact_data.dart @@ -0,0 +1,82 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/date_sortable_interface.dart'; +import 'package:flokk/data/git_event_data.dart'; +import 'package:flokk/data/tweet_data.dart'; + +class SocialContactData { + /* Populated at runtime */ + ContactData contact; + List tweets = []; + List gitEvents = []; + + //The number of new tweets since the last time user checked (populates the indicator) + List get newTweets => tweets.where((x) => x.createdAt.isAfter(lastCheckedTweets))?.toList() ?? []; + + //The number of new git events since the last time user checked (populates the indicator) + List get newGits => gitEvents.where((x) => x.createdAt.isAfter(lastCheckedGit))?.toList() ?? []; + + //Used to determine the level of activity for most active + int get points { + int pts = 0; + for (var n in gitEvents) { + switch (n.event.type) { + case "PushEvent": + case "ForkEvent": + case "CreateEvent": + pts++; + break; + } + } + pts += tweets.length; + return pts; + } + + //Get latest activity (can be GitEvent or Tweet) + DateSortable get latestActivity { + List sorted = []; + sorted.addAll(tweets); + sorted.addAll(gitEvents); + sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return sorted.isNotEmpty ? sorted.first : null; + } + + /* Serialized to json */ + String contactId; + + //Field to be updated whenever the user checks user social feeds + DateTime lastCheckedTweets = DateTime.fromMillisecondsSinceEpoch(0); + + DateTime lastCheckedGit = DateTime.fromMillisecondsSinceEpoch(0); + + //Field to be updated whenever the data for tweets have been updated + DateTime lastUpdatedTwitter = DateTime.fromMillisecondsSinceEpoch(0); + + //Field to be updated whenever the data for git events have been updated + DateTime lastUpdatedGit = DateTime.fromMillisecondsSinceEpoch(0); + + SocialContactData(); + + /* Json Serialization */ + + //The contact instance will be populated manually in the model, since we don't want to serialze contact data again (already serialized in contacts model) + factory SocialContactData.fromJson(Map json) { + return SocialContactData() + ..contactId = json["contactId"] as String + ..lastCheckedTweets = + json["lastCheckedTweets"] == null ? null : DateTime.parse(json["lastCheckedTweets"] as String) + ..lastCheckedGit = json["lastCheckedGit"] == null ? null : DateTime.parse(json["lastCheckedGit"] as String) + ..lastUpdatedTwitter = + json["lastUpdatedTwitter"] == null ? null : DateTime.parse(json["lastUpdatedTwitter"] as String) + ..lastUpdatedGit = json["lastUpdatedGit"] == null ? null : DateTime.parse(json["lastUpdatedGit"] as String); + } + + Map toJson() { + return { + "contactId": contactId, + "lastCheckedTweets": lastCheckedTweets?.toIso8601String(), + "lastCheckedGit": lastCheckedGit?.toIso8601String(), + "lastUpdatedTwitter": lastUpdatedTwitter?.toIso8601String(), + "lastUpdatedGit": lastUpdatedGit?.toIso8601String() + }; + } +} diff --git a/flokk_src/lib/data/tweet_data.dart b/flokk_src/lib/data/tweet_data.dart new file mode 100644 index 0000000..65791d2 --- /dev/null +++ b/flokk_src/lib/data/tweet_data.dart @@ -0,0 +1,105 @@ +import 'package:flokk/data/date_sortable_interface.dart'; +import 'package:flokk/data/twitter_user_data.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'tweet_data.g.dart'; + +@JsonSerializable(explicitToJson: true) +class Tweet implements DateSortable { + @JsonKey(name: "id_str") + String id; + + @JsonKey(name: "full_text") + String text; + + @JsonKey(defaultValue: false) + bool truncated; + + @JsonKey(defaultValue: false) + bool retweeted; + + @JsonKey(name: "retweet_count", defaultValue: 0) + int retweetCount; + + @JsonKey(name: "favorite_count", defaultValue: 0) + int favoriteCount; + + @JsonKey(name: "created_at") + String createdAtString; + + //Tweet dates use a Date string that is not compatible with DateTime.parse(), have to manually parse + @override + @JsonKey(ignore: true) + DateTime createdAt; + + //Url is populated at runtime based on tweet id + @JsonKey(ignore: true) + String url; + + TwitterUser user; + + Tweet(); + + static DateTime parseTwitterDateTime(String s) { + final r = RegExp(r"\w+\s(\w+)\s(\d+)\s([\d:]+)\s\+\d{4}\s(\d{4})"); + RegExpMatch m = r.firstMatch(s); + + String year = m?.group(4) ?? "1970"; + String month = m?.group(1) ?? "01"; + String day = m?.group(2) ?? "01"; + String time = m?.group(3) ?? "00:00:00"; + + switch (month) { + case "Jan": + month = "01"; + break; + case "Feb": + month = "02"; + break; + case "Mar": + month = "03"; + break; + case "Apr": + month = "04"; + break; + case "May": + month = "05"; + break; + case "Jun": + month = "06"; + break; + case "Jul": + month = "07"; + break; + case "Aug": + month = "08"; + break; + case "Sep": + month = "09"; + break; + case "Oct": + month = "10"; + break; + case "Nov": + month = "11"; + break; + case "Dec": + month = "12"; + break; + default: + month = "01"; + break; + } + + return DateTime.parse("$year-$month-$day $time Z"); + } + + factory Tweet.fromJson(Map json) { + Tweet tweet = _$TweetFromJson(json); + tweet.createdAt = parseTwitterDateTime(tweet.createdAtString); + tweet.url = "https://twitter.com/i/web/status/${tweet.id}"; + return tweet; + } + + Map toJson() => _$TweetToJson(this); +} diff --git a/flokk_src/lib/data/tweet_data.g.dart b/flokk_src/lib/data/tweet_data.g.dart new file mode 100644 index 0000000..5560818 --- /dev/null +++ b/flokk_src/lib/data/tweet_data.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tweet_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Tweet _$TweetFromJson(Map json) { + return Tweet() + ..id = json['id_str'] as String + ..text = json['full_text'] as String + ..truncated = json['truncated'] as bool ?? false + ..retweeted = json['retweeted'] as bool ?? false + ..retweetCount = json['retweet_count'] as int ?? 0 + ..favoriteCount = json['favorite_count'] as int ?? 0 + ..createdAtString = json['created_at'] as String + ..user = json['user'] == null + ? null + : TwitterUser.fromJson(json['user'] as Map); +} + +Map _$TweetToJson(Tweet instance) => { + 'id_str': instance.id, + 'full_text': instance.text, + 'truncated': instance.truncated, + 'retweeted': instance.retweeted, + 'retweet_count': instance.retweetCount, + 'favorite_count': instance.favoriteCount, + 'created_at': instance.createdAtString, + 'user': instance.user?.toJson(), + }; diff --git a/flokk_src/lib/data/twitter_user_data.dart b/flokk_src/lib/data/twitter_user_data.dart new file mode 100644 index 0000000..51d9368 --- /dev/null +++ b/flokk_src/lib/data/twitter_user_data.dart @@ -0,0 +1,49 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'twitter_user_data.g.dart'; + +@JsonSerializable() +class TwitterUser { + int id; + String name; + + @JsonKey(name: "screen_name", defaultValue: 0) + String screenName; + + String location; + String description; + String url; + + @JsonKey(defaultValue: false) + bool protected; + + @JsonKey(defaultValue: false) + bool verified; + + @JsonKey(name: "followers_count", defaultValue: 0) + int followersCount; + + @JsonKey(name: "friends_count", defaultValue: 0) + int friendsCount; + + @JsonKey(name: "listed_count", defaultValue: 0) + int listedCount; + + @JsonKey(name: "statuses_count", defaultValue: 0) + int statusesCount; + + @JsonKey(name: "profile_image_url_https") + String profileImageUrl; + + @JsonKey(name: "profile_background_image_url_https") + String profileBackgroundImageUrl; + + @JsonKey(name: "profile_use_background_image", defaultValue: false) + bool profileUseBackgroundImage; + + TwitterUser(); + + factory TwitterUser.fromJson(Map json) => _$TwitterUserFromJson(json); + + Map toJson() => _$TwitterUserToJson(this); +} diff --git a/flokk_src/lib/data/twitter_user_data.g.dart b/flokk_src/lib/data/twitter_user_data.g.dart new file mode 100644 index 0000000..81b1b33 --- /dev/null +++ b/flokk_src/lib/data/twitter_user_data.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'twitter_user_data.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TwitterUser _$TwitterUserFromJson(Map json) { + return TwitterUser() + ..id = json['id'] as int + ..name = json['name'] as String + ..screenName = json['screen_name'] as String ?? 0 + ..location = json['location'] as String + ..description = json['description'] as String + ..url = json['url'] as String + ..protected = json['protected'] as bool ?? false + ..verified = json['verified'] as bool ?? false + ..followersCount = json['followers_count'] as int ?? 0 + ..friendsCount = json['friends_count'] as int ?? 0 + ..listedCount = json['listed_count'] as int ?? 0 + ..statusesCount = json['statuses_count'] as int ?? 0 + ..profileImageUrl = json['profile_image_url_https'] as String + ..profileBackgroundImageUrl = + json['profile_background_image_url_https'] as String + ..profileUseBackgroundImage = + json['profile_use_background_image'] as bool ?? false; +} + +Map _$TwitterUserToJson(TwitterUser instance) => + { + 'id': instance.id, + 'name': instance.name, + 'screen_name': instance.screenName, + 'location': instance.location, + 'description': instance.description, + 'url': instance.url, + 'protected': instance.protected, + 'verified': instance.verified, + 'followers_count': instance.followersCount, + 'friends_count': instance.friendsCount, + 'listed_count': instance.listedCount, + 'statuses_count': instance.statusesCount, + 'profile_image_url_https': instance.profileImageUrl, + 'profile_background_image_url_https': instance.profileBackgroundImageUrl, + 'profile_use_background_image': instance.profileUseBackgroundImage, + }; diff --git a/flokk_src/lib/globals.dart b/flokk_src/lib/globals.dart new file mode 100644 index 0000000..a724caa --- /dev/null +++ b/flokk_src/lib/globals.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class AppGlobals { + static GlobalKey rootNavKey = GlobalKey(); + + static NavigatorState get nav => rootNavKey.currentState; +} diff --git a/flokk_src/lib/main.dart b/flokk_src/lib/main.dart new file mode 100644 index 0000000..c11bbc4 --- /dev/null +++ b/flokk_src/lib/main.dart @@ -0,0 +1,176 @@ +import 'package:flokk/_internal/components/no_glow_scroll_behavior.dart'; +import 'package:flokk/_internal/page_routes.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/bootstrap_command.dart'; +import 'package:flokk/commands/check_connection_command.dart'; +import 'package:flokk/commands/social/poll_social_command.dart'; +import 'package:flokk/globals.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/models/auth_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/models/github_model.dart'; +import 'package:flokk/models/twitter_model.dart'; +import 'package:flokk/services/github_rest_service.dart'; +import 'package:flokk/services/google_rest/google_rest_service.dart'; +import 'package:flokk/services/twitter_rest_service.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/welcome/welcome_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +//Developer hook to force login while testing locally (sidesteps Oauth flow) +const bool kForceWebLogin = bool.fromEnvironment('flokk.forceWebLogin', defaultValue: false); + +bool tryAndLoadDevSpike(BuildContext c) { + Widget spike; + + /// Load spike if we have one + if (spike != null) AppGlobals.nav.pushReplacement(PageRoutes.fade(() => spike)); + return spike != null; +} + +void main() { + /// Need to add this in order to run on Desktop. See https://github.com/flutter/flutter/wiki/Desktop-shells#target-platform-override + if (UniversalPlatform.isWindows || UniversalPlatform.isLinux || UniversalPlatform.isMacOS) { + debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; + } + + /// Initialize models, negotiate dependencies + var contactsModel = ContactsModel(); + var twitterModel = TwitterModel(contactsModel); + var githubModel = GithubModel(contactsModel); + var appModel = AppModel(contactsModel); + contactsModel.twitterModel = twitterModel; + contactsModel.gitModel = githubModel; + + /// Run MainApp, and provide all Models and Services + runApp( + MultiProvider( + providers: [ + /// MODELS + ChangeNotifierProvider.value(value: appModel), + ChangeNotifierProvider.value(value: contactsModel), + ChangeNotifierProvider.value(value: twitterModel), + ChangeNotifierProvider.value(value: githubModel), + ChangeNotifierProvider(create: (c) => AuthModel()), + + /// SERVICES + Provider(create: (_) => GoogleRestService()), + Provider(create: (_) => GithubRestService()), + Provider(create: (_) => TwitterRestService()), + + /// ROOT CONTEXT, Allows Commands to retrieve a 'safe' context that is not tied to any one view. Allows them to work on async tasks without issues. + Provider(create: (c) => c), + ], + child: MainApp(), + //child: SpikeApp(ImageTintSpike()), + ), + ); +} + +/// MainApp +/// * Binds to AppModel.theme, and injects current AppTheme to the rest of the App +/// * Runs the BootStrapperCommand +/// * Wraps a MaterialApp, assigning it a navKey so the Commands can access the root level navigator +class MainApp extends StatefulWidget { + @override + _MainAppState createState() => _MainAppState(); +} + +class _MainAppState extends State { + final GlobalKey _welcomePageKey = GlobalKey(); + CheckConnectionCommand _connectionChecker; + PollSocialCommand _pollSocialCommand; + bool _settingsLoaded = false; + + @override + void initState() { + /// Load appModel first, to fetch the appTheme ASAP + context.read().load().then((value) async { + /// Rebuild now that we have our loaded settings + setState(() => _settingsLoaded = true); + + /// /////////////////////////////////////////////// + /// Continous Background Services + // Connection checker, will run continuously until cancelled + _connectionChecker = CheckConnectionCommand(context)..execute(true); + + // Polling for social feeds, will run continuously until cancelled + _pollSocialCommand = PollSocialCommand(context)..execute(true); + + /// /////////////////////////////////////////////// + /// Bootstrap app + /// When bootstrap is complete, we know whether to sign in, or not + bool isSignedIn = await BootstrapCommand(context).execute(); + // First, allow dev-spike to take precedence over normal startup flow + if (tryAndLoadDevSpike(context)) return; + // Use welcome-page to complete remaining sign-in flow + WelcomePageState welcomePage = _welcomePageKey.currentState; + if (isSignedIn == true) { + // Login into the main app + welcomePage.refreshDataAndLoadApp(); + } else { + // Show login panel so user can sign-in + welcomePage.showPanel(true); + } + }); + super.initState(); + } + + @override + void dispose() { + _connectionChecker.cancel(); + _pollSocialCommand.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + /// If we've not loaded settings, + if (!_settingsLoaded) return Container(color: Colors.white); + + // TODO: Use platform brightness to determine default theme. // MediaQuery.of(context).platformBrightness; + /// Bind to AppModel.theme and get current theme + ThemeType themeType = context.select((value) => value.theme); + AppTheme theme = AppTheme.fromType(themeType); + + /// Disable shadows on web builds for better performance + if (UniversalPlatform.isWeb && !AppModel.enableShadowsOnWeb) { + Shadows.enabled = false; + } + + /// /////////////////////////////////////////////// + /// Main application + return Provider.value( + value: theme, // Provide the current theme to the entire app + child: MaterialApp( + title: "Flokk Contacts", + debugShowCheckedModeBanner: false, + navigatorKey: AppGlobals.rootNavKey, + + /// Pass active theme into MaterialApp + theme: theme.themeData, + + /// Home defaults to SplashView, BootstrapCommand will load the initial page + home: WelcomePage(key: _welcomePageKey), + + /// Wrap root navigator in various styling widgets + builder: (_, navigator) { + // Wrap root page in a builder, so we can make initial responsive tweaks based on MediaQuery + return Builder(builder: (c) { + //Responsive: Reduce size of our gutter scale when we're below a certain size + Insets.gutterScale = c.widthPx < PageBreaks.TabletPortrait ? .5 : 1; + // Disable all Material glow effects with [ NoGlowScrollBehavior ] + return ScrollConfiguration( + behavior: NoGlowScrollBehavior(), + child: navigator, + ); + }); + }, + ), + ); + } +} diff --git a/flokk_src/lib/models/abstract_model.dart b/flokk_src/lib/models/abstract_model.dart new file mode 100644 index 0000000..70577df --- /dev/null +++ b/flokk_src/lib/models/abstract_model.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/universal_file/universal_file.dart'; +import 'package:flutter/cupertino.dart'; + +abstract class AbstractModel extends ChangeNotifier { + UniversalFile _file; + + void reset([bool notify = true]) { + copyFromJson({}); + if (notify) notifyListeners(); + scheduleSave(); + } + + void notify() => notifyListeners(); + + //Make sure that we don't spam the file systems, cap saves to a max frequency + bool _isSaveScheduled = false; + + //[SB] This is a helper method + void scheduleSave() async { + if (_isSaveScheduled) return; + _isSaveScheduled = true; + await Future.delayed(Duration(seconds: 1)); + save(); + _isSaveScheduled = false; + } + + //Loads a string from disk, and parses it into ourselves. + Future load() async { + String string = await _file.read().catchError((e, s) => Log.e(e, stack: s)) ?? "{}"; + if (string != null) { + copyFromJson(jsonDecode(string)); + } + } + + Future save() async => _file.write(jsonEncode(toJson())); + + //Enable file serialization, remember to override the to/from serialization methods as well + void enableSerialization(String fileName) { + _file = UniversalFile(fileName); + } + + Map toJson() { + // This should be over-ridden in concrete class to enable serialization + throw UnimplementedError(); + } + + dynamic copyFromJson(Map json) { + // This should be over-ridden in concrete class to enable serialization + throw UnimplementedError(); + } + + List toList(dynamic json, Function(dynamic) fromJson) { + final List list = (json as Iterable)?.map((e) { + return e == null ? null : fromJson(e) as T; + })?.toList(); + return list; + } +} diff --git a/flokk_src/lib/models/app_model.dart b/flokk_src/lib/models/app_model.dart new file mode 100644 index 0000000..7b7f4c9 --- /dev/null +++ b/flokk_src/lib/models/app_model.dart @@ -0,0 +1,161 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/abstract_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flokk/views/search/search_engine.dart'; + +enum DashboardContactsSectionType { Favorites, RecentlyActive } + +enum DashboardSocialSectionType { All, Git, Twitter } + +/// ////////////////////////////////////////////////////// +/// APP MODEL - Holds global state/settings for various app components and views. +/// A mix of different values: Current theme, app version, settings, online status, selected sections etc. +/// Some of the values are serialized in app.settings file +class AppModel extends AbstractModel { + static const kCurrentVersion = "1.0.1"; + + static bool forceIgnoreGoogleApiCalls = false; + + static bool get enableShadowsOnWeb => true; + + static bool get enableAnimationsOnWeb => true; + + /// Toggle fpsMeter + static bool get showFps => false; + + /// Toggle Sketch Design Grid + static bool get showDesignGrid => false; + + /// Ignore limiting cooldown periods (tweets, git events, git repos, groups), always fetch for each request + static bool get ignoreCooldowns => false; + + ContactsModel contactsModel; + + AppModel(this.contactsModel) { + enableSerialization("app.settings"); + contactsModel.addListener(_handleContactsChanged); + } + + void _handleContactsChanged() { + /// Update search engine with latest results + searchEngine.contactsList = contactsModel.allContacts; + searchEngine.groupsList = contactsModel.allGroups; + + /// Watch selected contact and keep it updated when contacts model changes + if (selectedContact != null) { + selectedContact = contactsModel.getContactById(selectedContact.id); + } + } + + /// ////////////////////////////////////////////////// + /// Version Info (serialized) + String version = "0.0.0"; + + void upgradeToVersion(String value) { + // Any version specific upgrade checks can go here + version = value; + scheduleSave(); + } + + /// ///////////////////////////////////////////////// + /// Current dashboard sections (serialized) + DashboardContactsSectionType get dashContactsSection => _dashContactsSection; + DashboardContactsSectionType _dashContactsSection; + + set dashContactsSection(DashboardContactsSectionType value) { + _dashContactsSection = value; + notifyListeners(); + } + + DashboardSocialSectionType get dashSocialSection => _dashSocialSection; + DashboardSocialSectionType _dashSocialSection; + + set dashSocialSection(DashboardSocialSectionType value) { + _dashSocialSection = value; + notifyListeners(); + } + + /// ////////////////////////////////////////////////// + /// Selected edit target, controls visibility of the edit panel and selected rows in the various views + ContactData get selectedContact => _selectedContact; + ContactData _selectedContact; + + void touchSelectedSocial() => contactsModel.touchSocialById(selectedContact?.id); + + /// Current selected edit target, controls visibility of the edit panel + set selectedContact(ContactData value) { + _selectedContact = value; + notifyListeners(); + } + + bool get showSocialTabOnInfoView => _showSocialTabOnInfoView; + bool _showSocialTabOnInfoView = false; + + set showSocialTabOnInfoView(bool value) { + _showSocialTabOnInfoView = value; + if (_showSocialTabOnInfoView) { + touchSelectedSocial(); + } + notifyListeners(); + } + + /// ////////////////////////////////////////////////// + /// Global search settings, this is a changeNotifier itself, views can bind directly to it if they need. + SearchEngine searchEngine = SearchEngine(); + + /// ////////////////////////////////////////////////// + /// Holds current page type, synchronizes leftMenu with the mainContent + PageType get currentMainPage => _currentMainPage; + PageType _currentMainPage; + + set currentMainPage(PageType value) { + _currentMainPage = value; + notifyListeners(); + } + + /// ////////////////////////////////////////// + /// Current connection status + bool get isOnline => _isOnline; + bool _isOnline = true; + + set isOnline(bool value) { + _isOnline = value; + notifyListeners(); + } + + /// ////////////////////////////////////////// + /// Current Theme (serialized) + ThemeType get theme => _theme; + ThemeType _theme; + + set theme(ThemeType value) { + _theme = value; + scheduleSave(); + notifyListeners(); + } + + @override + void copyFromJson(Map json) { + var v = ThemeType.values; + int theme = json["_theme"] ?? 0; + _theme = v[theme.clamp(0, v.length)]; + _dashContactsSection = DashboardContactsSectionType.values[json['_dashContactsSection'] ?? 0]; + _dashSocialSection = DashboardSocialSectionType.values[json['_dashSocialSection'] ?? 0]; + version = json['version']; + } + + @override + Map toJson() => { + "_theme": _theme.index, + 'version': version, + '_dashContactsSection': _dashContactsSection.index, + '_dashSocialSection': _dashSocialSection.index + }; + + /// [SB] Just for easy testing, remove later + void nextTheme() { + theme = (theme == ThemeType.FlockGreen_Dark) ? ThemeType.FlockGreen : ThemeType.FlockGreen_Dark; + } +} diff --git a/flokk_src/lib/models/auth_model.dart b/flokk_src/lib/models/auth_model.dart new file mode 100644 index 0000000..98bfd36 --- /dev/null +++ b/flokk_src/lib/models/auth_model.dart @@ -0,0 +1,80 @@ +import 'package:flokk/_internal/log.dart'; +import "package:flokk/_internal/utils/string_utils.dart"; +import "package:flokk/models/abstract_model.dart"; +import 'package:google_sign_in/google_sign_in.dart'; + +class AuthModel extends AbstractModel { + String googleRefreshToken; + String googleEmail; + String googleSyncToken; + DateTime _expiry = DateTime.fromMillisecondsSinceEpoch(0); + GoogleSignIn googleSignIn; //instance of google sign in; only set if web + + AuthModel() { + enableSerialization("auth.dat"); + } + + //Helper method to quickly lookup last known auth state, does not mean user is necessarily verified, the auth token may be expired. + bool get hasAuthKey => !StringUtils.isEmpty(_googleAccessToken); + + bool get isExpired => expiry.isBefore(DateTime.now()); + + DateTime get expiry => _expiry; + + bool get isAuthenticated => !isExpired && hasAuthKey; + + //Using a setExpiry() method instead of a setter, because it's a bit weird to have different values (int for set vs DateTime for get). + //Setting it with int makes more sense because the auth result returns expiry time in seconds. + //Getting it with DateTime makes more sense because it's easier to deal with and check against. + void setExpiry(int seconds) { + _expiry = DateTime.now().add(Duration(seconds: seconds)); + } + + ///////////////////////////////////////////////////////////////////// + // Access Token + String _googleAccessToken; + + String get googleAccessToken => _googleAccessToken; + + set googleAccessToken(String value) { + _googleAccessToken = value; + notifyListeners(); + } + + @override + void reset([bool notify = true]) { + Log.p("[AuthModel] Reset"); + _googleAccessToken = null; + googleRefreshToken = null; + googleSyncToken = null; + googleEmail = null; + _expiry = null; + if (googleSignIn != null) { + googleSignIn.disconnect(); + } + super.reset(notify); + } + + ///////////////////////////////////////////////////////////////////// + // Define serialization methods + + @override + void copyFromJson(Map json) { + this + .._googleAccessToken = json["_googleAccessToken"] + ..googleRefreshToken = json["googleRefreshToken"] + ..googleSyncToken = json["googleSyncToken"] + ..googleEmail = json["googleEmail"] + .._expiry = json["_expiry"] != null ? DateTime.parse(json["_expiry"]) : DateTime.fromMillisecondsSinceEpoch(0); + ; + } + + @override + Map toJson() => { + "_googleAccessToken": _googleAccessToken, + "googleRefreshToken": googleRefreshToken, + "googleSyncToken": googleSyncToken, + "googleEmail": googleEmail, + "_expiry": _expiry.toString() + }; +} diff --git a/flokk_src/lib/models/contacts_model.dart b/flokk_src/lib/models/contacts_model.dart new file mode 100644 index 0000000..649efca --- /dev/null +++ b/flokk_src/lib/models/contacts_model.dart @@ -0,0 +1,265 @@ +import 'package:flokk/_internal/log.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/data/social_contact_data.dart'; +import 'package:flokk/models/abstract_model.dart'; +import 'package:flokk/models/github_model.dart'; +import 'package:flokk/models/twitter_model.dart'; +import 'package:flokk/services/google_rest/google_rest_service.dart'; +import 'package:tuple/tuple.dart'; + +class ContactsModel extends AbstractModel { + final DateTime epoch = DateTime.fromMillisecondsSinceEpoch(0); + + final gitEventsCooldown = Duration(minutes: 5); + final tweetsCooldown = Duration(minutes: 1); + final contactGroupsCooldown = Duration(seconds: 20); + + DateTime lastUpdatedGroups = DateTime.fromMillisecondsSinceEpoch(0); //can't just use epoch because "only static members can be used in initializers" + + ContactsModel() { + enableSerialization("contacts.dat"); + } + + TwitterModel twitterModel; + GithubModel gitModel; + + //Groups + List get allGroups => _allGroups ?? []; + List _allGroups = [GroupData()..name = ""]; + + set allGroups(List value) { + _allGroups = value; + _updateContactsGroups(); + notifyListeners(); + } + + GroupData getGroupById(String value) => _allGroups.firstWhere((g) => g.id == value, orElse: () => null); + + GroupData getGroupByName(String value) => _allGroups.firstWhere((g) => g.name == value, orElse: () => null); + + //Contacts List + List get activeContacts => allContacts.where((c) => !c.isDeleted).toList(); + + List get starred => allContacts.where((c) => c.isStarred).toList(); + + List get allContacts => _allContacts; + List _allContacts = []; + + set allContacts(List value) { + _allContacts = value; + _updateSocialContacts(); + notifyListeners(); + } + + ContactData getContactById(String id) => _allContacts.firstWhere((c) => c.id == id, orElse: () => null); + + void addContact(ContactData contact) { + _allContacts.add(contact); + _updateSocialContacts(); + notifyListeners(); + } + + void removeContact(ContactData contact) { + _allContacts.removeWhere((c) => c.id == contact.id); + _updateSocialContacts(); + notifyListeners(); + } + + void swapContactById(ContactData newContact) { + ContactData oldContact = getContactById(newContact.id); + if (oldContact != null) { + //[SB] Keep isStarred in sync when we swap contents since this is injected by the [updateContactsWithGroupData] fxn. + newContact.isStarred = oldContact.isStarred; + newContact.groupList = oldContact.groupList; + _allContacts[_allContacts.indexOf(oldContact)] = newContact; + notifyListeners(); + } + } + + void swapGroupById(GroupData newGroup) { + for (var i = _allGroups.length; i-- > 0;) { + if (_allGroups[i].id != newGroup.id) continue; + _allGroups[i] = newGroup; + notifyListeners(); + break; + } + } + + //Social contacts + List _allSocialContacts = []; + + List get allSocialContacts { + _updateSocialContacts(); + return _allSocialContacts; + } + + void touchSocialById(String id) { + SocialContactData social = getSocialById(id); + if (social != null) { + social.lastCheckedTweets = DateTime.now(); + social.lastCheckedGit = DateTime.now(); + notifyListeners(); + scheduleSave(); + } + } + + void clearGitCooldown(ContactData contact) { + getSocialById(contact.id)?.lastUpdatedGit = epoch; + getSocialById(contact.id)?.lastCheckedGit = epoch; + } + + void clearTwitterCooldown(ContactData contact) { + getSocialById(contact.id)?.lastUpdatedTwitter = epoch; + getSocialById(contact.id)?.lastCheckedTweets = epoch; + } + + bool canRefreshGitEventsFor(String gitUsername) { + DateTime lastUpdate = getSocialContactByGit(gitUsername)?.lastUpdatedGit ?? epoch; + return DateTime.now().difference(lastUpdate) > gitEventsCooldown; + } + + bool canRefreshTweetsFor(String twitterHandle) { + DateTime lastUpdate = getSocialContactByTwitter(twitterHandle)?.lastUpdatedTwitter ?? epoch; + return DateTime.now().difference(lastUpdate) > tweetsCooldown; + } + + bool get canRefreshContactGroups => DateTime.now().difference(lastUpdatedGroups ?? epoch) > contactGroupsCooldown; + + //Updates the timestamps when social feeds are refreshed for contact + void updateSocialTimestamps({String twitterHandle, String gitUsername}) { + if (!StringUtils.isEmpty(twitterHandle)) { + getSocialContactByTwitter(twitterHandle)?.lastUpdatedTwitter = DateTime.now(); + } + if (!StringUtils.isEmpty(gitUsername)) { + getSocialContactByGit(gitUsername)?.lastUpdatedGit = DateTime.now(); + } + } + + void updateContactDataGithubValidity(String gitUsername, bool isValid) { + allContacts?.firstWhere((x) => x.gitUsername == gitUsername, orElse: () => null)?.hasValidGit = isValid; + } + + void updateContactDataTwitterValidity(String twitterHandle, bool isValid) { + allContacts?.firstWhere((x) => x.twitterHandle == twitterHandle, orElse: () => null)?.hasValidTwitter = isValid; + } + + ContactData getContactByGit(String gitUsername) => + allContacts?.firstWhere((x) => x.gitUsername == gitUsername, orElse: () => null); + + ContactData getContactByTwitter(String twitterHandle) => + allContacts?.firstWhere((x) => x.twitterHandle == twitterHandle, orElse: () => null); + + SocialContactData getSocialContactByGit(String gitUsername) => getSocialById(getContactByGit(gitUsername)?.id); + + SocialContactData getSocialContactByTwitter(String twitterHandle) => + getSocialById(getContactByTwitter(twitterHandle)?.id); + + //Get a list of contacts with the most activity (based on their calculated "points" for each social activity) + List get mostActiveSocialContacts => + allSocialContacts..sort((a, b) => b.points.compareTo(a.points)); + + //Get a list of contacts with the most recent activity + List get mostRecentSocialContacts => allSocialContacts + ..sort((a, b) => (b.latestActivity?.createdAt ?? epoch).compareTo(a.latestActivity?.createdAt ?? epoch)); + + SocialContactData getSocialById(String id) { + if (StringUtils.isEmpty(id)) return null; + return allSocialContacts?.firstWhere((c) => c.contactId == id, orElse: () => null); + } + + //Get a list of contacts with upcoming dates (repeated contacts are expected if they have multiple events that are upcoming) + List> get upcomingDateContacts { + //List of all dates (birthday and events) with their contact id + List> flattenedDates = allContacts + .map((contact) => contact.allDates.map((x) => Tuple2(contact.id, x)).toList()) + .toList() + .expand((element) => element) + .where((element) => element.item2.daysTilAnniversary < 90) //limit to upcoming dates for next 3 months + .toList(); + + //Sort by the closest upcoming dates + flattenedDates.sort((a, b) => a.item2.daysTilAnniversary.compareTo(b.item2.daysTilAnniversary)); + + List> contactsWithDates = []; + for (var n in flattenedDates) { + contactsWithDates.add(Tuple2(getContactById(n.item1), n.item2)); + } + return contactsWithDates; + } + + void _updateContactsGroups() { + if (_allContacts.isEmpty) return; + + /// Clear all known existing groups + _allContacts..forEach((c) => c.groupList = []); + + /// Set the labels for each contact (groupList) + for (GroupData g in _allGroups) { + if (g.groupType == GroupType.UserContactGroup) { + for (String id in g.members) { + ContactData contact = getContactById(id); + if (contact != null) { + contact.groupList.add(g); + // print("name: ${contact.nameFull} labels: ${contact.groupList.join(',')}"); + } + } + } + //Set the isStarred property for each ContactData who is member of Starred contact group + if (g.id == GoogleRestService.kStarredGroupId) { + for (ContactData c in _allContacts) { + c.isStarred = g.members.contains(c.id); + } + } + } + } + + void _updateSocialContacts() { + //clean up any social contacts that are NOT found in all contacts + _allSocialContacts.removeWhere((x) => !_allContacts.any((c) => c.id == x.contactId)); + + //create social contact if needed, otherwise just update tweets/events + for (var n in _allContacts) { + if (n.hasAnySocial) { + if (!_allSocialContacts.any((x) => x.contactId == n.id)) { + _allSocialContacts.add(SocialContactData() + ..contactId = n.id + ..contact = n + ..gitEvents = gitModel.getEventsByContact(n) + ..tweets = twitterModel.getTweetsByContact(n)); + } else { + SocialContactData socialContact = _allSocialContacts.firstWhere((x) => x.contactId == n.id); + socialContact.contact = n; + socialContact.gitEvents = gitModel.getEventsByContact(n); + socialContact.tweets = twitterModel.getTweetsByContact(n); + } + } + } + } + + @override + void reset([bool notify = true]) { + Log.p("[ContactsModel] Reset"); + copyFromJson({}); + super.reset(notify); + } + + ///////////////////////////////////////////////////////////////////// + // Define serialization methods + + //Json Serialization + @override + ContactsModel copyFromJson(Map value) { + _allContacts = toList(value['_allContacts'], (j) => ContactData.fromJson(j)) ?? []; + _allGroups = toList(value['_allGroups'], (j) => GroupData.fromJson(j)) ?? []; + _allSocialContacts = toList(value['_allSocialContacts'], (j) => SocialContactData.fromJson(j)) ?? []; + _updateSocialContacts(); + return this; + } + + @override + Map toJson() { + return {'_allContacts': _allContacts, '_allGroups': _allGroups, '_allSocialContacts': _allSocialContacts}; + } +} diff --git a/flokk_src/lib/models/github_model.dart b/flokk_src/lib/models/github_model.dart new file mode 100644 index 0000000..b3c3231 --- /dev/null +++ b/flokk_src/lib/models/github_model.dart @@ -0,0 +1,202 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/git_event_data.dart'; +import 'package:flokk/data/git_repo_data.dart'; +import 'package:flokk/models/abstract_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:github/github.dart'; +import 'package:tuple/tuple.dart'; + +class GithubModel extends AbstractModel { + final DateTime epoch = DateTime.fromMillisecondsSinceEpoch(0); + final expiry = Duration(days: 30); //the period of which to cull events based on createdAt + final repoStaleTime = Duration(hours: 72); + ContactsModel contactsModel; + + //Each event has a repo reference, however not all fields populated in repo object because full set of data not returned in service call. Need separate calls to look up and rely on model to inject as needed + Map> _eventsHash = {}; + Map _reposHash = {}; + + GithubModel(this.contactsModel) { + enableSerialization("github.dat"); + } + + @override + void scheduleSave() { + cull(); + super.scheduleSave(); + } + + /// ////////////////////////////////////////////////////////////////// + /// Serialization + + @override + GithubModel copyFromJson(Map json) { + _eventsHash = (json["_eventsHash"] as Map)?.map((key, value) => + MapEntry>(key, (value as List).map((x) => GitEvent.fromJson(x))?.toList())) ?? + {}; + _reposHash = (json["_reposHash"] as Map) + ?.map((key, value) => MapEntry(key, GitRepo.fromJson(value))) ?? + {}; + return this; + } + + @override + Map toJson() => {"_eventsHash": _eventsHash, "_reposHash": _reposHash}; + + /// ////////////////////////////////////////////////////////////////// + /// Public API + bool get isLoading => _isLoading; + bool _isLoading = false; + + set isLoading(bool isLoading) { + _isLoading = isLoading; + notifyListeners(); + } + + ///////////////////////////////////////////////////////////////////// + // Repos + bool repoExists(String repoFullName) => _reposHash.containsKey(repoFullName); + + bool repoIsStale(String repoFullName) => + !repoExists(repoFullName) || + DateTime.now().difference(_reposHash[repoFullName].lastUpdated ?? epoch) > repoStaleTime; + + + //Get all user repos + List get allRepos { + return _reposHash.values.toList(); + } + + + void addRepos(List repos) { + for (var n in repos) { + _reposHash[n.repository.fullName] = n; + } + notifyListeners(); + } + + void addRepo(GitRepo repo) { + _reposHash[repo.repository.fullName] = repo; + } + + + //Get repos associated with contact (either it was part of their Event, or else they own it) + List getReposByContact(ContactData contact) { + if (contact.hasGit) { + List events = getEventsByContact(contact); + + //Distinct (no duplicates) list of repo names from contact events + List repoNames = events.map((x) => x.event.repo.name).toSet().toList(); + + //Get repos either owned by contact or is part of the contact events + List repos = _reposHash.values + .where((x) => + //x.owner.login == contact.gitUsername || + repoNames.any((e) => e == x.repository.fullName)) + .map((x) => x + ..contacts = [contact] + ..latestActivityDate = x.repository.updatedAt) + .toList(); + return repos; + } else { + return []; + } + } + + //Get the most popular repos + List get popularRepos { + List> popular = []; + for (var n in allRepos) { + int pts = 0; + pts += (n.repository.stargazersCount ?? 0) * 3; + pts += (n.repository.forksCount ?? 0) * 2; + pts += (n.repository.watchersCount ?? 0) * 1; + + //All events associated with this repo + List associatedEvents = _eventsHash.values + .expand((x) => x) //flatten + .where((x) => x.event.repo.name == n.repository.fullName) + .toList(); + + //All contacts associated with this repo + List associatedContacts = associatedEvents + .map((x) => x.event.actor.login) + .toSet() + .toList() //get distinct git usernames for each event + .map((x) => contactsModel.getContactByGit(x)) //get contact by gitusername + .toList(); + + //Get the latest date from the events associated with this repo or else fall back to repository.updatedAt + associatedEvents?.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + DateTime latestActivityDate = associatedEvents.isNotEmpty + ? associatedEvents.first?.createdAt ?? n.repository.updatedAt + : n.repository.updatedAt; + + if (associatedContacts.isNotEmpty) { + popular.add(Tuple2( + pts, + n + ..contacts = associatedContacts + ..latestActivityDate = latestActivityDate)); + } + } + popular.sort((a, b) => b.item1.compareTo(a.item1)); + List repos = popular.map((x) => x.item2).toList(); + return repos; + } + + + ///////////////////////////////////////////////////////////////////// + // Events + //Get all events sorted by time + List get allEvents { + final sorted = _eventsHash.values.toList().expand((x) => x).toList(); + sorted.sort((a, b) => (b?.createdAt ?? epoch).compareTo(a?.createdAt ?? epoch)); + + //inject the repos data for each event + for (var n in sorted) { + n.repository = _reposHash[n?.event?.repo?.name]?.repository ?? + Repository(id: n?.event?.repo?.id, name: n?.event?.repo?.name, htmlUrl: n?.event?.repo?.htmlUrl); + } + + return sorted; + } + + //Get events for single contact + List getEventsByContact(ContactData contact) { + if (_eventsHash.containsKey(contact.gitUsername)) { + return _eventsHash[contact.gitUsername]; + } + return []; + } + + + void addEvents(String gitUsername, List events) { + final current = DateTime.now(); + _eventsHash[gitUsername] = events.where((x) => (current.difference(x.createdAt)) < expiry).toList(); + notifyListeners(); + } + + void removeEvents(String gitUsername) { + _eventsHash.remove(gitUsername); + } + + void cull() { + //remove old events + final current = DateTime.now(); + for (List n in _eventsHash.values) { + n.removeWhere((x) => current.difference(x.createdAt) >= expiry); + } + _eventsHash.removeWhere((key, value) => value.isEmpty); + + //cull unused repos + final repoKeys = _reposHash.keys.toList(); + for (String n in repoKeys) { + if (allEvents.any((x) => x.event.repo.name == n)) { + continue; + } + _reposHash.remove(n); + } + notifyListeners(); + } +} diff --git a/flokk_src/lib/models/twitter_model.dart b/flokk_src/lib/models/twitter_model.dart new file mode 100644 index 0000000..b7b5632 --- /dev/null +++ b/flokk_src/lib/models/twitter_model.dart @@ -0,0 +1,113 @@ +import "package:flokk/_internal/utils/string_utils.dart"; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/tweet_data.dart'; +import "package:flokk/models/abstract_model.dart"; +import 'package:flokk/models/contacts_model.dart'; +import 'package:tuple/tuple.dart'; + +class TwitterModel extends AbstractModel { + final expiry = Duration(days: 30); //the period of which to cull tweets based on createdAt + + ContactsModel contactsModel; + + TwitterModel(this.contactsModel) { + enableSerialization("twitter.dat"); + } + + @override + void scheduleSave() { + cull(); + super.scheduleSave(); + } + + /// ////////////////////////////////////////////////////////////////// + /// Serialization + @override + TwitterModel copyFromJson(Map json) { + _twitterAccessToken = json["_twitterAccessToken"] ?? ""; + _tweetHash = (json["_tweetHash"] as Map)?.map((key, value) => + MapEntry>(key, (value as List).map((x) => Tweet.fromJson(x))?.toList())) ?? + {}; + return this; + } + + @override + Map toJson() { + return {"_twitterAccessToken": _twitterAccessToken, "_tweetHash": _tweetHash}; + } + + /// ////////////////////////////////////////////////////////////////// + /// Public API + bool get isLoading => _isLoading; + bool _isLoading = false; + + set isLoading(bool isLoading) { + _isLoading = isLoading; + notifyListeners(); + } + + //Helper method to quickly lookup last known auth state, does not mean user is necessarily verified, the auth token may be expired. + bool get isAuthenticated => !StringUtils.isEmpty(_twitterAccessToken); + + ///////////////////////////////////////////////////////////////////// + // Access Token + String _twitterAccessToken; + + String get twitterAccessToken => _twitterAccessToken; + + set twitterAccessToken(String value) { + _twitterAccessToken = value; + notifyListeners(); + } + + ///////////////////////////////////////////////////////////////////// + // Tweets + Map> _tweetHash = {}; + + //Get all tweets sorted by time + List get allTweets { + final sorted = _tweetHash.values.toList().expand((x) => x).toList(); + sorted.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + return sorted; + } + + //Get all tweets sorted by popularity + List get popularTweets { + List> popular = []; + for (var n in allTweets) { + int pts = 0; + pts += n.retweetCount; + pts += n.favoriteCount; + popular.add(Tuple2(pts, n)); + } + popular.sort((a, b) => b.item1.compareTo(a.item1)); + return popular.map((x) => x.item2).toList(); + } + + //Get tweets for single contact + List getTweetsByContact(ContactData contact) { + if (_tweetHash.containsKey(contact.twitterHandle)) { + return _tweetHash[contact.twitterHandle]; + } + return []; + } + + void addTweets(String twitterHandle, List tweets) { + final current = DateTime.now(); + _tweetHash[twitterHandle] = tweets.where((x) => (current.difference(x.createdAt)) < expiry).toList(); + notifyListeners(); + } + + void removeTweets(String twitterHandle) { + _tweetHash.remove(twitterHandle); + } + + void cull() { + final current = DateTime.now(); + for (List n in _tweetHash.values) { + n.removeWhere((x) => current.difference(x.createdAt) >= expiry); + } + _tweetHash.removeWhere((key, value) => value.isEmpty); + notifyListeners(); + } +} diff --git a/flokk_src/lib/services/github_rest_service.dart b/flokk_src/lib/services/github_rest_service.dart new file mode 100644 index 0000000..7c1a4a4 --- /dev/null +++ b/flokk_src/lib/services/github_rest_service.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/api_keys.dart'; +import 'package:flokk/data/git_event_data.dart'; +import 'package:flokk/data/git_repo_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:github/github.dart'; + +class GithubRestService { + Map _getAuthHeader() { + final String key = Uri.encodeQueryComponent(ApiKeys().githubKey); + final String secret = Uri.encodeQueryComponent(ApiKeys().githubSecret); + final Uint8List bytes = AsciiEncoder().convert("$key:$secret"); + final String auth = base64Encode(bytes); + return { + "Authorization": "Basic $auth", + }; + } + + Future>> getUserEvents(String githubUsername) async { + String url = "https://api.github.com/users/$githubUsername/events"; + + HttpResponse response = await HttpClient.get(url, headers: _getAuthHeader()); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + + List events = []; + if (response?.success == true) { + List> data = List.from(jsonDecode(response.body)); + for (Map n in data) { + events.add(GitEvent()..event = Event.fromJson(n)); + } + } + return ServiceResult(events, response); + } + + Future>> getUserRepos(String githubUsername) async { + String url = "https://api.github.com/users/$githubUsername/repos"; + + HttpResponse response = await HttpClient.get(url, headers: _getAuthHeader()); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + + List repos = []; + if (response?.success == true) { + List> data = List.from(jsonDecode(response.body)); + for (Map n in data) { + repos.add(GitRepo() + ..repository = Repository.fromJson(n) + ..lastUpdated = DateTime.now()); + } + } + return ServiceResult(repos, response); + } + + Future> getRepo(String repoName) async { + String url = "https://api.github.com/repos/$repoName"; + + HttpResponse response = await HttpClient.get(url, headers: _getAuthHeader()); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + + GitRepo repo; + if (response?.success == true) { + repo = GitRepo() + ..repository = Repository.fromJson(jsonDecode(response.body)) + ..lastUpdated = DateTime.now(); + } + return ServiceResult(repo, response); + } +} diff --git a/flokk_src/lib/services/google_rest/google_rest_auth_service.dart b/flokk_src/lib/services/google_rest/google_rest_auth_service.dart new file mode 100644 index 0000000..17d0968 --- /dev/null +++ b/flokk_src/lib/services/google_rest/google_rest_auth_service.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/_internal/utils/rest_utils.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/services/service_result.dart'; + +class GoogleRestAuthService { + final String discoveryUrl = "https://accounts.google.com/.well-known/openid-configuration"; + final String authUrl = "https://oauth2.googleapis.com/token"; + final String redirectUri = "https://oauth2.googleapis.com/callback"; + final String deviceCodeGrantType = "urn:ietf:params:oauth:grant-type:device_code"; + final String scope = "email https://www.googleapis.com/auth/contacts"; + + final String _clientId; + final String _clientSecret; + + GoogleRestAuthService(this._clientId, this._clientSecret); + + Future> getAuthEndpoint() async { + //print("Request: $discoveryUrl"); + HttpResponse discoverResponse = await HttpClient.get(discoveryUrl); + //print("Response: ${discoverResponse.statusCode} / ${discoverResponse.body}"); + if (discoverResponse.success) { + Map body = jsonDecode(discoverResponse.body); + + String url = "${body["device_authorization_endpoint"]}?"; + url += RESTUtils.encodeParams({"client_id": _clientId, "scope": scope}); + //print("Request: $url"); + HttpResponse authResponse = await HttpClient.post(url); + //print("Response: ${authResponse.statusCode} / ${authResponse.body}"); + + GoogleAuthEndpointInfo endpoint; + if (authResponse.success) { + Map userAuth = jsonDecode(authResponse.body); + endpoint = GoogleAuthEndpointInfo( + deviceCode: userAuth["device_code"], + expiresIn: userAuth["expires_in"], + interval: userAuth["interval"], + userCode: userAuth["user_code"], + verificationUrl: userAuth["verification_url"]); + } + return ServiceResult(endpoint, authResponse); + } + return ServiceResult(null, discoverResponse); + } + + Future> authorizeDevice(String deviceCode) async => + await _getAuthResults(deviceCode: deviceCode); + + Future> refresh(String refreshToken) async => + await _getAuthResults(refreshToken: refreshToken); + + Future> _getAuthResults({String deviceCode, String refreshToken}) async { + String grant = !StringUtils.isEmpty(refreshToken) ? "refresh_token" : deviceCodeGrantType; + Map params = {"client_id": _clientId, "client_secret": _clientSecret, "grant_type": grant}; + if (!StringUtils.isEmpty(refreshToken)) { + params.putIfAbsent("refreshToken", () => refreshToken); + } else { + params.putIfAbsent("device_code", () => deviceCode); + } + HttpResponse response = await HttpClient.post("$authUrl?${RESTUtils.encodeParams(params)}"); + print("Response: ${response.statusCode} / ${response.body}"); + GoogleAuthResults results; + if (response.success) { + Map userAccess = jsonDecode(response.body); + results = GoogleAuthResults( + accessToken: userAccess["access_token"], + expiresIn: userAccess["expires_in"], + refreshToken: userAccess["refresh_token"], + tokenType: userAccess["token_type"], + idToken: userAccess["id_token"]); + } + + return ServiceResult(results, response); + } +} + +class GoogleAuthEndpointInfo { + final String deviceCode; + final int expiresIn; + final int interval; + final String userCode; + final String verificationUrl; + + GoogleAuthEndpointInfo({this.deviceCode, this.expiresIn, this.interval, this.userCode, this.verificationUrl}); +} + +class GoogleAuthResults { + final String accessToken; + final int expiresIn; + final String refreshToken; + final String tokenType; + final String idToken; + Map profile; + + String get email => _email; + String _email; + + GoogleAuthResults({this.accessToken, this.expiresIn, this.refreshToken, this.tokenType, this.idToken}) { + profile = jsonDecode(getProfileFromToken(idToken)); + _email = profile["email"]; + } + + String getProfileFromToken(String idToken) { + List parts = idToken.split("."); + var decoder = Base64Codec(); + String payload = decoder.normalize(parts[1]); + return utf8.decode(decoder.decode(payload)); + } +} diff --git a/flokk_src/lib/services/google_rest/google_rest_contact_groups_service.dart b/flokk_src/lib/services/google_rest/google_rest_contact_groups_service.dart new file mode 100644 index 0000000..4cc9bc2 --- /dev/null +++ b/flokk_src/lib/services/google_rest/google_rest_contact_groups_service.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:googleapis/people/v1.dart'; +import 'package:tuple/tuple.dart'; + +class GoogleRestContactGroupsService { + Future, String>>> get(String accessToken, {String nextPageToken}) async { + String url = "https://people.googleapis.com/v1/contactGroups" + "?access_token=$accessToken" + "&pageSize=1000"; + + if (nextPageToken != null) { + url += "&pageToken=$nextPageToken"; + } + + HttpResponse response = await HttpClient.get(url); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + List groups = []; + String token = ""; + if (response?.success == true) { + Map data = jsonDecode(response.body); + token = data["nextPageToken"] ?? ""; + List groupsData = data["contactGroups"]; + for (Map n in groupsData) { + GroupData group = groupFromJson(n); + groups.add(group); + } + } + return ServiceResult(Tuple2, String>(groups, token), response); + } + + Future> getById(String accessToken, String groupId) async { + String url = "https://people.googleapis.com/v1/$groupId" + "?access_token=$accessToken" + "&maxMembers=1000"; + + HttpResponse response = await HttpClient.get(url); + //print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + GroupData group; + if (response?.success == true) { + group = groupFromJson(jsonDecode(response.body)); + } + return ServiceResult(group, response); + } + + Future> create(String accessToken, GroupData group) async { + String url = "https://people.googleapis.com/v1/contactGroups"; + + HttpResponse response = await HttpClient.post(url, + headers: {"Authorization": "Bearer $accessToken"}, body: jsonEncode({"contactGroup": groupToJson(group)})); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + GroupData newGroup; + if (response?.success == true) { + Map data = jsonDecode(response.body); + newGroup = groupFromJson(data); + } + return ServiceResult(newGroup, response); + } + + Future> delete(String accessToken, GroupData group) async { + String url = "https://people.googleapis.com/v1/${group.id}"; + + HttpResponse response = await HttpClient.delete( + url, + headers: {"Authorization": "Bearer $accessToken"}, + ); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + return ServiceResult(null, response); + } + + Future> modify(String accessToken, GroupData group, + {List addContacts, List removeContacts}) async { + String url = "https://people.googleapis.com/v1/${group.id}/members:modify"; + + Map data = {}; + if (addContacts?.isNotEmpty ?? false) { + data["resourceNamesToAdd"] = addContacts.map((x) => x.id).toList(); + } + if (removeContacts?.isNotEmpty ?? false) { + data["resourceNamesToRemove"] = removeContacts.map((x) => x.id).toList(); + } + + HttpResponse response = await HttpClient.post( + url, + headers: {"Authorization": "Bearer $accessToken"}, + body: jsonEncode(data), + ); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + return ServiceResult(null, response); + } + + Future> set(String accessToken, GroupData group) async { + String url = "https://people.googleapis.com/v1/${group.id}"; + + HttpResponse response = await HttpClient.put(url, + headers: {"Authorization": "Bearer $accessToken"}, body: jsonEncode({"contactGroup": groupToJson(group)})); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + GroupData updatedContact; + if (response?.success == true) { + //updated contact group returned from server + Map data = jsonDecode(response.body); + updatedContact = groupFromJson(data); + } + return ServiceResult(updatedContact, response); + } + + GroupData groupFromJson(Map json) { + final g = ContactGroup.fromJson(json); + //print(g.name); + final groupData = GroupData() + ..id = g.resourceName ?? "" + ..etag = g.etag ?? "" + ..name = g.name ?? "" + ..memberCount = g.memberCount ?? 0 + ..members = g.memberResourceNames ?? []; + + switch (g.groupType) { + case "GROUP_TYPE_UNSPECIFIED": + groupData.groupType = GroupType.GroupTypeUnspecified; + break; + case "USER_CONTACT_GROUP": + groupData.groupType = GroupType.UserContactGroup; + break; + case "SYSTEM_CONTACT_GROUP": + groupData.groupType = GroupType.SystemContactGroup; + break; + default: + groupData.groupType = GroupType.GroupTypeUnspecified; + } + + return groupData; + } + + Map groupToJson(GroupData group) { + final contactGroup = ContactGroup() + ..resourceName = group.id ?? "" + ..etag = group.etag ?? "" + ..name = group.name ?? "" + ..memberCount = group.memberCount ?? 0 + ..memberResourceNames = group.members ?? []; + return contactGroup.toJson(); + } +} diff --git a/flokk_src/lib/services/google_rest/google_rest_contacts_service.dart b/flokk_src/lib/services/google_rest/google_rest_contacts_service.dart new file mode 100644 index 0000000..597d0b6 --- /dev/null +++ b/flokk_src/lib/services/google_rest/google_rest_contacts_service.dart @@ -0,0 +1,434 @@ +import 'dart:convert'; + +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/_internal/utils/date_utils.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:googleapis/people/v1.dart'; + +class GoogleRestContactsService { + static final String kTwitterParam = "Twitter"; + static final String kGitParam = "Github"; + + //The requested fields to fetch, full list here: https://developers.google.com/people/api/rest/v1/people.connections/list?hl=ru + static const List kAllPersonFields = [ + "addresses", + "ageRanges", + "biographies", + "birthdays", + "braggingRights", + "coverPhotos", + "emailAddresses", + "events", + "genders", + "imClients", + "interests", + "locales", + "memberships", + "metadata", + "names", + "nicknames", + "occupations", + "organizations", + "phoneNumbers", + "photos", + "relations", + "relationshipInterests", + "relationshipStatuses", + "residences", + "sipAddresses", + "skills", + "taglines", + "urls", + "userDefined" + ]; + + //List of update fields https://developers.google.com/people/api/rest/v1/people/updateContact + //Note that these are just a subset of the full list of PersonFields (kAllPersonFields) + //Removed "memberships" field, because that should only be edited by the group API calls + static const kAllUpdatePersonFields = [ + "addresses", + "biographies", + "birthdays", + "emailAddresses", + "events", + "genders", + "imClients", + "interests", + "locales", + "names", + "nicknames", + "occupations", + "organizations", + "phoneNumbers", + "relations", + "residences", + "sipAddresses", + "urls", + "userDefined" + ]; + + //Debug hook to test many contacts, if the multiplier is > 1, it will clone your contacts list that many times. + static int contactsMultiplier = 1; + + Future> getAll(String accessToken, String syncToken) async { + List list = []; + bool requestSyncToken = syncToken == ""; + int retryCount = 0; + String nextPageToken = ""; + ServiceResult result; + if (requestSyncToken) { + //Request new sync token + result = await get(accessToken, requestSyncToken: requestSyncToken); + } else { + //Attempt to use existing token (possible that it's expired) + result = await get(accessToken, syncToken: syncToken); + + //We get a 400 status if the token is expired, try again and request new sync token + if (result.response.errorType == NetErrorType.denied) { + requestSyncToken = true; + result = await get(accessToken, requestSyncToken: requestSyncToken); + } + } + + list = result.content.contacts; + nextPageToken = result.content.nextPageToken; + syncToken = result.content.syncToken; + + //Attempt to load all chunks of data, just for edge cases that have > 2000 contacts (max page size) + while ((nextPageToken?.isNotEmpty ?? false) && retryCount < 3) { + ServiceResult result = await get( + accessToken, + nextPageToken: nextPageToken, + ); + + if (result.success) { + list.addAll(result.content.contacts); + nextPageToken = result.content.nextPageToken; + } else { + //Possible for subsequent calls to fail and return 503 + retryCount++; + } + } + return result; + } + + //List of valid PersonFields can be found here https://developers.google.com/people/api/rest/v1/people.connections/list + Future> get(String accessToken, + {List personFields, String nextPageToken, String syncToken, bool requestSyncToken}) async { + // Default to all person fields if none are passed + personFields ??= kAllPersonFields; + String url = "https://people.googleapis.com/v1/people/me/connections?" + "access_token=$accessToken" + "&personFields=${kAllPersonFields.join(',')}" + "&sortOrder=FIRST_NAME_ASCENDING" + "&pageSize=2000"; + + if (!StringUtils.isEmpty(nextPageToken)) { + url += "&pageToken=$nextPageToken"; + } + + if (!StringUtils.isEmpty(syncToken)) { + url += "&syncToken=$syncToken"; + } + + if (requestSyncToken != null) { + url += "&requestSyncToken=$requestSyncToken"; + } + HttpResponse response = await HttpClient.get(url); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + List list; + String newNextPageToken = ""; + String newSyncToken = ""; + if (response?.success == true) { + Map data = jsonDecode(response.body); + newNextPageToken = data["nextPageToken"] ?? ""; + newSyncToken = data["nextSyncToken"] ?? ""; + List entries = data["connections"] ?? []; + print("token: $newNextPageToken ${entries.length} out of ${data["totalPeople"]}"); + list = []; + for (int i = 0, l = entries.length; i < l; i++) { + ContactData c = contactFromJson(entries[i]); + list.add(c); + } + if (contactsMultiplier > 1) { + List copy = list.toList(); + for (var i = contactsMultiplier; i-- > 0;) { + list.addAll(copy); + } + } + } + return ServiceResult(GetContactsResult(list, nextPageToken: nextPageToken, syncToken: newSyncToken), response); + } + + //List of valid PersonFields can be found here https://developers.google.com/people/api/rest/v1/people/updateContact + Future> set(String accessToken, ContactData contact, {List personFields}) async { + personFields ??= kAllUpdatePersonFields; + String url = "https://people.googleapis.com/v1/${contact.googleId}:updateContact?" + "updatePersonFields=${personFields.join(',')}"; + + HttpResponse response = await HttpClient.patch(url, + headers: {"Authorization": "Bearer $accessToken"}, body: jsonEncode(contactToJson(contact))); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + ContactData updatedContact; + if (response?.success == true) { + //updated contact returned from server + updatedContact = contactFromJson(jsonDecode(response.body)); + } + return ServiceResult(updatedContact, response); + } + + Future> create(String accessToken, ContactData contact) async { + String url = "https://people.googleapis.com/v1/people:createContact"; + + HttpResponse response = await HttpClient.post(url, + headers: {"Authorization": "Bearer $accessToken"}, body: jsonEncode(contactToJson(contact))); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + ContactData newContact; + if (response?.success == true) { + //new contact with proper "resourceName"(googleId) and "etag" properties set + newContact = contactFromJson(jsonDecode(response.body)); + } + return ServiceResult(newContact, response); + } + + Future> delete(String accessToken, ContactData contact) async { + String url = "https://people.googleapis.com/v1/${contact.googleId}:deleteContact"; + + HttpResponse response = await HttpClient.delete(url, headers: {"Authorization": "Bearer $accessToken"}); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + return ServiceResult(null, response); + } + + //Takes a base64 encoded image + Future> updatePic(String accessToken, ContactData contact, String profilePic) async { + String url = "https://people.googleapis.com/v1/${contact.googleId}:updateContactPhoto"; + + Map bodyJson = {"photoBytes": profilePic, "personFields": "names,photos"}; + + HttpResponse response = + await HttpClient.patch(url, headers: {"Authorization": "Bearer $accessToken"}, body: jsonEncode(bodyJson)); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + ContactData updatedContact; + if (response?.success == true) { + updatedContact = contactFromJson(jsonDecode(response.body)["person"]); + } + return ServiceResult(updatedContact, response); + } + + Future> deletePic(String accessToken, ContactData contact) async { + String url = "https://people.googleapis.com/v1/${contact.googleId}:deleteContactPhoto"; + + HttpResponse response = await HttpClient.delete(url, headers: {"Authorization": "Bearer $accessToken"}); + print("REQUEST: $url /// RESPONSE: ${response.statusCode}"); + return ServiceResult(null, response); + } + + ContactData contactFromJson(Map json) { + // c.fileAs = + Person p = Person.fromJson(json); + ContactData c = ContactData() + ..googleId = p.resourceName ?? "" + ..etag = p.etag ?? "" + ..nameGiven = p.names?.first?.givenName ?? "" + ..nameGivenPhonetic = p.names?.first?.phoneticGivenName ?? "" + ..nameMiddle = p.names?.first?.middleName ?? "" + ..nameMiddlePhonetic = p.names?.first?.phoneticMiddleName ?? "" + ..nameFamily = p.names?.first?.familyName ?? "" + ..nameFull = p.names?.first?.displayName ?? "" + ..namePrefix = p.names?.first?.honorificPrefix ?? "" + ..nameSuffix = p.names?.first?.honorificSuffix ?? "" + ..nickname = p.nicknames?.first?.value ?? "" + ..profilePic = p.photos?.first?.url ?? "" + ..isDefaultPic = p.photos?.first?.default_ ?? false + ..isDeleted = p.metadata?.deleted ?? false + ..jobCompany = p.organizations?.first?.name ?? "" + ..jobDepartment = p.organizations?.first?.department ?? "" + ..jobTitle = p.organizations?.first?.title ?? "" + ..notes = p.biographies?.first?.value ?? "" + ..emailList = p.emailAddresses + ?.where((x) => x?.metadata?.source?.type != "DOMAIN_PROFILE") + ?.map((x) => EmailData() + ..value = x.value + ..type = x.formattedType) + ?.toList() ?? + [] + ..phoneList = p.phoneNumbers + ?.map((x) => PhoneData() + ..number = x.value + ..type = x.formattedType) + ?.toList() ?? + [] + ..websiteList = p.urls + ?.map((x) => WebsiteData() + ..href = x.value + ..type = x.formattedType) + ?.toList() ?? + [] + ..imList = p.imClients + ?.map((x) => InstantMessageData() + ..username = x.username + ..type = x.formattedType) + ?.toList() ?? + [] + ..relationList = p.relations + ?.map((x) => RelationData() + ..person = x.person + ..type = x.formattedType) + ?.toList() ?? + [] + ..eventList = p.events + ?.map((x) => EventData() + ..date = DateTime(x.date?.year ?? 0, x.date?.month ?? 1, x.date?.day ?? 1) + ..type = x.formattedType) + ?.toList() ?? + [] + ..addressList = p.addresses + ?.map((x) => AddressData() + ..city = x.city + ..country = x.country + ..poBox = x.poBox + ..street = x.streetAddress + ..formattedAddress = x.extendedAddress + ..postcode = x.postalCode + ..region = x.region + ..type = x.formattedType) + ?.toList() ?? + []; + + if (p.birthdays?.isNotEmpty ?? false) { + c.birthday = BirthdayData() + ..date = DateTime( + p.birthdays.first.date?.year ?? 0, p.birthdays.first.date?.month ?? 1, p.birthdays.first.date?.day ?? 1) + ..text = p.birthdays.first?.text ?? ""; + + if (c.birthday.date == DateTime(0, 1, 1)) { + try { + c.birthday.date = DateFormats.google.parse(c.birthday.text) ?? DateTime(0, 1, 1); + } catch (e) { + c.birthday.date = DateTime(0, 1, 1); + } + } + } + + if (p.userDefined?.isNotEmpty ?? false) { + c.customFields = + Map.fromIterable(p.userDefined, key: (x) => (x as UserDefined).key, value: (x) => (x as UserDefined).value); + + /// Inject known custom fields into Contact, and remove from Map + c.twitterHandle = c.customFields.remove(kTwitterParam); + c.gitUsername = c.customFields.remove(kGitParam); + } + + c.groupList = []; //will be populated by GroupData + + // Don't allow empty lists into the app domain, makes our life much easier on the UI end :) + c.trimLists(); + + return c; + } + + Map contactToJson(ContactData contact) { + Person p = Person() + ..resourceName = contact.googleId ?? "" + ..etag = contact.etag ?? "" + ..names = [ + Name() + ..givenName = contact.nameGiven ?? "" + ..phoneticGivenName = contact.nameGivenPhonetic ?? "" + ..middleName = contact.nameMiddle ?? "" + ..phoneticMiddleName = contact.nameMiddlePhonetic ?? "" + ..familyName = contact.nameFamily ?? "" + ..displayName = contact.nameFull ?? "" + ..honorificPrefix = contact.namePrefix ?? "" + ..honorificSuffix = contact.nameSuffix ?? "" + ] + ..nicknames = [Nickname()..value = contact.nickname ?? ""] + ..organizations = [ + Organization() + ..name = contact.jobCompany ?? "" + ..department = contact.jobDepartment ?? "" + ..title = contact.jobTitle ?? "" + ] + ..biographies = [Biography()..value = contact.notes ?? ""] + ..emailAddresses = contact.emailList + ?.map((x) => EmailAddress() + ..value = x.value + ..type = x.type) + ?.toList() + ..phoneNumbers = contact.phoneList + ?.map((x) => PhoneNumber() + ..value = x.number + ..type = x.type) + ?.toList() + ..urls = contact.websiteList + ?.map((x) => Url() + ..value = x.href + ..type = x.type) + ?.toList() + ..imClients = contact.imList + ?.map((x) => ImClient() + ..username = x.username + ..type = x.type) + ?.toList() + ..relations = contact.relationList + ?.map((x) => Relation() + ..person = x.person + ..type = x.type) + ?.toList() + ..events = contact.eventList?.map((x) { + Date d = Date() + ..year = x.date?.year + ..month = x.date?.month + ..day = x.date?.day; + return Event() + ..date = d + ..type = x.type; + })?.toList() + ..addresses = contact.addressList + ?.map((x) => Address() + ..city = x.city + ..country = x.country + ..poBox = x.poBox + ..streetAddress = x.street + ..extendedAddress = x.formattedAddress + ..postalCode = x.postcode + ..region = x.region + ..type = x.type) + ?.toList(); + + if (contact.birthday?.isEmpty == false ?? false) { + p.birthdays = [Birthday()..text = contact.birthday.text]; + } + + if (contact.hasGit || contact.hasTwitter || (contact.customFields?.isNotEmpty ?? false)) { + /// Inject known custom fields back into the payload + void addUserDefined(String key, dynamic value) { + if (value == null) return; + p.userDefined.add(UserDefined() + ..key = key + ..value = value); + } + + p.userDefined = []; + + /// Inject each customField that's been set into the Person + contact.customFields.forEach(addUserDefined); + + /// Inject our own, known fields + if (contact.hasTwitter) addUserDefined(kTwitterParam, contact.twitterHandle); + if (contact.hasGit) addUserDefined(kGitParam, contact.gitUsername); + } + //NOTE: Person.Photos are not needed in this, they are read-only + return p.toJson(); + } +} + +class GetContactsResult { + final List contacts; + final String nextPageToken; + final String syncToken; + + GetContactsResult(this.contacts, {this.nextPageToken, this.syncToken}); +} diff --git a/flokk_src/lib/services/google_rest/google_rest_service.dart b/flokk_src/lib/services/google_rest/google_rest_service.dart new file mode 100644 index 0000000..067621d --- /dev/null +++ b/flokk_src/lib/services/google_rest/google_rest_service.dart @@ -0,0 +1,12 @@ +import 'package:flokk/api_keys.dart'; +import 'package:flokk/services/google_rest/google_rest_auth_service.dart'; +import 'package:flokk/services/google_rest/google_rest_contact_groups_service.dart'; +import 'package:flokk/services/google_rest/google_rest_contacts_service.dart'; + +class GoogleRestService { + static String kStarredGroupId = "contactGroups/starred"; + + final GoogleRestContactsService contacts = GoogleRestContactsService(); + final GoogleRestContactGroupsService groups = GoogleRestContactGroupsService(); + final GoogleRestAuthService auth = GoogleRestAuthService(ApiKeys().googleClientId, ApiKeys().googleClientSecret); +} diff --git a/flokk_src/lib/services/service_result.dart b/flokk_src/lib/services/service_result.dart new file mode 100644 index 0000000..4fe7009 --- /dev/null +++ b/flokk_src/lib/services/service_result.dart @@ -0,0 +1,10 @@ +import 'package:flokk/_internal/http_client.dart'; + +class ServiceResult { + final HttpResponse response; + final T content; + + bool get success => response.success; + + ServiceResult(this.content, this.response); +} diff --git a/flokk_src/lib/services/twitter_rest_service.dart b/flokk_src/lib/services/twitter_rest_service.dart new file mode 100644 index 0000000..a4d81b7 --- /dev/null +++ b/flokk_src/lib/services/twitter_rest_service.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flokk/_internal/http_client.dart'; +import 'package:flokk/api_keys.dart'; +import 'package:flokk/data/tweet_data.dart'; +import 'package:flokk/services/service_result.dart'; +import 'package:flutter/foundation.dart'; +import 'package:html/parser.dart'; + +class TwitterRestService { + //Insert path to CORS proxy, needed for web builds + final String proxy = kIsWeb ? "http://localhost:8888/" : ""; + + Future> getAuth() async { + final String authUrl = "${proxy}https://api.twitter.com/oauth2/token"; + final String key = Uri.encodeQueryComponent(ApiKeys().twitterKey); + final String secret = Uri.encodeQueryComponent(ApiKeys().twitterSecret); + final Uint8List bytes = AsciiEncoder().convert("$key:$secret"); + final String auth = base64Encode(bytes); + + HttpResponse response = await HttpClient.post("$authUrl", + headers: {"Authorization": "Basic $auth", "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"}, + body: "grant_type=client_credentials"); + + TwitterAuthResult result; + if (response.success) { + Map data = jsonDecode(response.body); + result = TwitterAuthResult(tokenType: data["token_type"], accessToken: data["access_token"]); + } + + + return ServiceResult(result, response); + } + + Future>> getTweets(String accessToken, String screenName) async { + String url = "${proxy}https://api.twitter.com/1.1/statuses/user_timeline.json" + "?screen_name=$screenName" + "&tweet_mode=extended"; + + HttpResponse response = await HttpClient.get(url, headers: {"Authorization": "Bearer $accessToken"}); + + print("REQUEST: $url /// RESPONSE: ${response?.statusCode}"); + + List tweets = []; + if (response.success) { + List> tweetsData = List.from(jsonDecode(response.body)); + for (int i = 0; i < tweetsData?.length ?? 0; i++) { + Map data = tweetsData[i]; + Tweet t = Tweet.fromJson(data); + t.text = parse(t.text).documentElement.text; + tweets.add(t); + } + } + + return ServiceResult(tweets, response); + } +} + +class TwitterAuthResult { + final String tokenType; + final String accessToken; + + TwitterAuthResult({this.tokenType, this.accessToken}); +} diff --git a/flokk_src/lib/strings.dart b/flokk_src/lib/strings.dart new file mode 100644 index 0000000..8110c5a --- /dev/null +++ b/flokk_src/lib/strings.dart @@ -0,0 +1,47 @@ +class _Strings { + static _Strings instance = _Strings(); + + String TITLE_CONTACTS_PAGE = "Contacts"; + String TITLE_WHATS_HAPPENING = "What's happening this week?"; + String TITLE_ADD_CONTACT = "Add Contact"; + String TITLE_EDIT_CONTACT = "Edit Contact"; + + String BTN_OK = "Ok"; + String BTN_CANCEL = "Cancel"; + String BTN_SIGN_IN = "Sign In"; + String BTN_SIGN_OUT = "Sign Out"; + String BTN_COMPLETE = "Complete"; + String BTN_SAVE = "Save"; + + String LBL_WELCOME = "Welcome!"; + String LBL_NAME_FIRST = "First Name"; + String LBL_NAME_MIDDLE = "Middle Name"; + String LBL_NAME_LAST = "Last Name"; + String LBL_STEP_X = "Step {0}"; + + String ERR_DEVICE_OAUTH_FAILED_TITLE = "Unable to connect to your account."; + String ERR_DEVICE_OAUTH_FAILED_MSG = "Please make sure you've completed the sign-in process in your browser."; + + String GOOGLE_OAUTH_TITLE = "GOOGLE SIGN-IN"; + String GOOGLE_OAUTH_INSTRUCTIONS_1 = + "In order to import your Google Contacts, you'll need to authorize this application using your web browser."; + String GOOGLE_OAUTH_INSTRUCTIONS_2 = "Copy this code to your clipboard by clicking the icon or selecting the text:"; + String GOOGLE_OAUTH_INSTRUCTIONS_3 = "Navigate to the following link in your web browser, and enter the above code:"; + String GOOGLE_OAUTH_INSTRUCTIONS_4 = "Press the button below when you've completed signup:"; +} + +_Strings get S => _Strings.instance; + +extension AddSupplant on String { + String sup([dynamic v0, dynamic v1, dynamic v2, dynamic v3, dynamic v4, dynamic v5, dynamic v6]) { + var _s = this; + if (v0 != null) _s = _s.replaceAll("{0}", "$v0"); + if (v1 != null) _s = _s.replaceAll("{1}", "$v1"); + if (v2 != null) _s = _s.replaceAll("{2}", "$v2"); + if (v3 != null) _s = _s.replaceAll("{3}", "$v3"); + if (v4 != null) _s = _s.replaceAll("{4}", "$v4"); + if (v5 != null) _s = _s.replaceAll("{5}", "$v5"); + if (v6 != null) _s = _s.replaceAll("{6}", "$v6"); + return _s; + } +} diff --git a/flokk_src/lib/styled_components/buttons/base_styled_button.dart b/flokk_src/lib/styled_components/buttons/base_styled_button.dart new file mode 100644 index 0000000..6fe7122 --- /dev/null +++ b/flokk_src/lib/styled_components/buttons/base_styled_button.dart @@ -0,0 +1,136 @@ +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +/// //////////////////////////////////////////////////// +/// STYLED BTN - BASE CLASS FOR ALL OTHER BTNS +/// //////////////////////////////////////////////////// +class BaseStyledBtn extends StatefulWidget { + final Widget child; + final VoidCallback onPressed; + final Function(bool) onFocusChanged; + final Function(bool) onHighlightChanged; + final Color bgColor; + final Color focusColor; + final Color hoverColor; + final Color downColor; + final EdgeInsets contentPadding; + final double minWidth; + final double minHeight; + final double borderRadius; + final bool useBtnText; + final bool autoFocus; + + final ShapeBorder shape; + + final Color outlineColor; + + const BaseStyledBtn({ + Key key, + this.child, + this.onPressed, + this.onFocusChanged, + this.onHighlightChanged, + this.bgColor, + this.focusColor, + this.contentPadding, + this.minWidth, + this.minHeight, + this.borderRadius, + this.hoverColor, + this.downColor, + this.shape, + this.useBtnText = true, + this.autoFocus = false, + this.outlineColor = Colors.transparent, + }) : super(key: key); + + @override + _BaseStyledBtnState createState() => _BaseStyledBtnState(); +} + +class _BaseStyledBtnState extends State { + FocusNode _focusNode; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(debugLabel: "", canRequestFocus: true); + _focusNode.addListener(() { + if (_focusNode.hasFocus != _isFocused) { + setState(() => _isFocused = _focusNode.hasFocus); + widget.onFocusChanged?.call(_isFocused); + } + }); + } + + @override + void dispose() { + _focusNode?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return Container( + decoration: BoxDecoration( + color: widget.bgColor ?? theme.surface, + borderRadius: BorderRadius.circular(widget.borderRadius ?? Corners.s5), + boxShadow: _isFocused + ? [ + BoxShadow( + color: theme.focus.withOpacity(0.25), offset: Offset.zero, blurRadius: 8.0, spreadRadius: 0.0), + BoxShadow( + color: widget.bgColor ?? theme.surface, offset: Offset.zero, blurRadius: 8.0, spreadRadius: -4.0), + ] + : [], + ), + foregroundDecoration: _isFocused + ? ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide( + width: 1.8, + color: theme.focus, + ), + borderRadius: BorderRadius.circular(widget.borderRadius ?? Corners.s5), + ), + ) + : null, + child: RawMaterialButton( + focusNode: _focusNode, + autofocus: widget.autoFocus, + textStyle: widget.useBtnText ? TextStyles.Btn : null, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + splashColor: Colors.transparent, + mouseCursor: SystemMouseCursors.click, + elevation: 0, + hoverElevation: 0, + highlightElevation: 0, + focusElevation: 0, + fillColor: Colors.transparent, + hoverColor: widget.hoverColor ?? theme.surface, + highlightColor: widget.downColor ?? theme.accent1.withOpacity(.1), + focusColor: widget.focusColor?? Colors.grey.withOpacity(0.35), + child: Opacity( + child: Padding( + padding: widget.contentPadding ?? EdgeInsets.all(Insets.m), + child: widget.child, + ), + opacity: widget.onPressed != null ? 1 : .7, + ), + constraints: BoxConstraints(minHeight: widget.minHeight ?? 0, minWidth: widget.minWidth ?? 0), + onPressed: widget.onPressed, + shape: widget.shape ?? + RoundedRectangleBorder( + side: BorderSide(color: widget.outlineColor, width: 1.5), + borderRadius: BorderRadius.circular(widget.borderRadius ?? Corners.s5)), + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/buttons/colored_icon_btn.dart b/flokk_src/lib/styled_components/buttons/colored_icon_btn.dart new file mode 100644 index 0000000..8337524 --- /dev/null +++ b/flokk_src/lib/styled_components/buttons/colored_icon_btn.dart @@ -0,0 +1,70 @@ +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// //////////////////////////////////////////////////// +/// Transparent icon button that changes it's btn color on mouse-over +/// //////////////////////////////////////////////////// +class ColorShiftIconBtn extends StatelessWidget { + final Function() onPressed; + + final AssetImage icon; + final double size; + final Color color; + final Color bgColor; + final EdgeInsets padding; + final double minWidth; + final double minHeight; + final Function(bool) onFocusChanged; + final bool shrinkWrap; + + const ColorShiftIconBtn( + this.icon, { + Key key, + this.onPressed, + this.color, + this.size, + this.padding, + this.onFocusChanged, + this.bgColor, + this.minWidth, + this.minHeight, + this.shrinkWrap = false, + }) : assert((icon is AssetImage) || (icon is IconData)), + super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + bool _mouseOver = false; + return StatefulBuilder( + builder: (_, setState) { + Color iconColor = (color ?? theme.grey); + if (_mouseOver) { + iconColor = ColorUtils.shiftHsl(iconColor, theme.isDark ? .2 : -.2); + } + return MouseRegion( + onEnter: (_) => setState(() => _mouseOver = true), + onExit: (_) => setState(() => _mouseOver = false), + child: BaseStyledBtn( + minHeight: minHeight ?? (shrinkWrap ? 0 : 42), + minWidth: minWidth ?? (shrinkWrap ? 0 : 42), + bgColor: bgColor ?? Colors.transparent, + downColor: theme.bg2.withOpacity(.35), + hoverColor: bgColor ?? Colors.transparent, + onFocusChanged: onFocusChanged, + contentPadding: padding ?? EdgeInsets.all(Insets.sm), + child: IgnorePointer( + child: StyledImageIcon(icon, size: (size ?? 22.0), color: iconColor), + ), + onPressed: onPressed), + ); + }, + ); + } +} diff --git a/flokk_src/lib/styled_components/buttons/ok_cancel_btn_row.dart b/flokk_src/lib/styled_components/buttons/ok_cancel_btn_row.dart new file mode 100644 index 0000000..b34dbf9 --- /dev/null +++ b/flokk_src/lib/styled_components/buttons/ok_cancel_btn_row.dart @@ -0,0 +1,31 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/strings.dart'; +import 'package:flokk/styled_components/buttons/primary_btn.dart'; +import 'package:flokk/styled_components/buttons/secondary_btn.dart'; +import 'package:flokk/styles.dart'; +import 'package:flutter/material.dart'; + +class OkCancelBtnRow extends StatelessWidget { + final Function() onOkPressed; + final Function() onCancelPressed; + final String okLabel; + final String cancelLabel; + final double minHeight; + + const OkCancelBtnRow( + {Key key, this.onOkPressed, this.onCancelPressed, this.okLabel, this.cancelLabel, this.minHeight}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (onOkPressed != null) PrimaryTextBtn(okLabel ?? S.BTN_OK.toUpperCase(), onPressed: onOkPressed), + HSpace(Insets.m), + if (onCancelPressed != null) + SecondaryTextBtn(cancelLabel ?? S.BTN_CANCEL.toUpperCase(), onPressed: onCancelPressed), + ], + ); + } +} diff --git a/flokk_src/lib/styled_components/buttons/primary_btn.dart b/flokk_src/lib/styled_components/buttons/primary_btn.dart new file mode 100644 index 0000000..4eae474 --- /dev/null +++ b/flokk_src/lib/styled_components/buttons/primary_btn.dart @@ -0,0 +1,43 @@ +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class PrimaryBtn extends StatelessWidget { + final Widget child; + final Function() onPressed; + final bool bigMode; + + const PrimaryBtn({Key key, this.child, this.onPressed, this.bigMode = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return BaseStyledBtn( + minWidth: bigMode ? 160 : 78, + minHeight: bigMode ? 60 : 42, + contentPadding: EdgeInsets.all(bigMode ? Insets.l : Insets.m), + bgColor: theme.accent1Darker, + hoverColor: theme.isDark ? theme.accent1 : theme.accent1Dark, + downColor: theme.accent1Darker, + borderRadius: bigMode ? Corners.s8 : Corners.s5, + child: child, + onPressed: onPressed, + ); + } +} + +class PrimaryTextBtn extends StatelessWidget { + final String label; + final Function() onPressed; + final bool bigMode; + + const PrimaryTextBtn(this.label, {Key key, this.onPressed, this.bigMode = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + TextStyle txtStyle = (bigMode ? TextStyles.Callout : TextStyles.Footnote).textColor(Colors.white); + return PrimaryBtn(bigMode: bigMode, onPressed: onPressed, child: Text(label, style: txtStyle)); + } +} diff --git a/flokk_src/lib/styled_components/buttons/secondary_btn.dart b/flokk_src/lib/styled_components/buttons/secondary_btn.dart new file mode 100644 index 0000000..56722a7 --- /dev/null +++ b/flokk_src/lib/styled_components/buttons/secondary_btn.dart @@ -0,0 +1,84 @@ +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SecondaryTextBtn extends StatelessWidget { + final String label; + final Function() onPressed; + + const SecondaryTextBtn(this.label, {Key key, this.onPressed}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + TextStyle txtStyle = TextStyles.Footnote.textColor(theme.accent1Darker); + return SecondaryBtn(onPressed: onPressed, child: Text(label, style: txtStyle)); + } +} + +class SecondaryIconBtn extends StatelessWidget { + /// Must be either an `AssetImage` for an `ImageIcon` or an `IconData` for a regular `Icon` + final AssetImage icon; + final Function() onPressed; + final Color color; + + const SecondaryIconBtn(this.icon, {Key key, this.onPressed, this.color}) + : assert((icon is AssetImage) || (icon is IconData)), + super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return SecondaryBtn( + onPressed: onPressed, + minHeight: 36, + minWidth: 36, + contentPadding: Insets.sm, + child: StyledImageIcon(icon, size: 20, color: color ?? theme.grey), + ); + } +} + +class SecondaryBtn extends StatefulWidget { + final Widget child; + final Function() onPressed; + final double minWidth; + final double minHeight; + final double contentPadding; + final Function(bool) onFocusChanged; + + const SecondaryBtn({Key key, this.child, this.onPressed, this.minWidth, this.minHeight, this.contentPadding, this.onFocusChanged}) + : super(key: key); + + @override + _SecondaryBtnState createState() => _SecondaryBtnState(); +} + +class _SecondaryBtnState extends State { + bool _isMouseOver = false; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return MouseRegion( + onEnter: (_) => setState(() => _isMouseOver = true), + onExit: (_) => setState(() => _isMouseOver = false), + child: BaseStyledBtn( + minWidth: widget.minWidth ?? 78, + minHeight: widget.minHeight ?? 42, + contentPadding: EdgeInsets.all(widget.contentPadding ?? Insets.m), + bgColor: theme.surface, + outlineColor: (_isMouseOver ? theme.accent1 : theme.grey).withOpacity(.35), + hoverColor: theme.surface, + onFocusChanged: widget.onFocusChanged, + downColor: theme.greyWeak.withOpacity(.35), + borderRadius: Corners.s5, + child: IgnorePointer(child: widget.child), + onPressed: widget.onPressed, + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/buttons/transparent_btn.dart b/flokk_src/lib/styled_components/buttons/transparent_btn.dart new file mode 100644 index 0000000..a9e6781 --- /dev/null +++ b/flokk_src/lib/styled_components/buttons/transparent_btn.dart @@ -0,0 +1,112 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TransparentBtn extends StatelessWidget { + final Widget child; + final Function() onPressed; + final bool bigMode; + final EdgeInsets contentPadding; + final Color bgColor; + final Color hoverColor; + final Color downColor; + final double borderRadius; + + const TransparentBtn( + {Key key, + this.onPressed, + this.child, + this.bigMode = false, + this.contentPadding, + this.bgColor, + this.hoverColor, + this.downColor, + this.borderRadius}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return BaseStyledBtn( + minWidth: 30, + minHeight: 30, + contentPadding: contentPadding ?? + EdgeInsets.symmetric( + horizontal: bigMode ? Insets.sm : Insets.xs, + vertical: bigMode ? Insets.sm : Insets.xs, + ), + bgColor: bgColor ?? Colors.transparent, + hoverColor: hoverColor ?? (theme.isDark ? ColorUtils.shiftHsl(theme.bg1, .2) : theme.bg2.withOpacity(.35)), + downColor: downColor ?? ColorUtils.shiftHsl(theme.bg2, .1), + borderRadius: borderRadius ?? Corners.s5, + child: child, + onPressed: onPressed, + ); + } +} + +class TransparentTextBtn extends StatelessWidget { + final String label; + final Function() onPressed; + final Color color; + final bool bigMode; + final TextStyle style; + final Color bgColor; + + const TransparentTextBtn(this.label, + {Key key, this.onPressed, this.color, this.bigMode = false, this.style, this.bgColor}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + Color c = color ?? theme.accent1; + return TransparentBtn( + bigMode: bigMode, + bgColor: bgColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text(label, style: style ?? (bigMode ? TextStyles.Body1 : TextStyles.T1).textColor(c))], + ), + onPressed: onPressed, + ); + } +} + +class TransparentIconAndTextBtn extends StatelessWidget { + final String label; + final AssetImage icon; + final double iconSize; + final Function() onPressed; + final Color color; + final Color textColor; + final bool bigMode; + final TextStyle style; + + const TransparentIconAndTextBtn(this.label, this.icon, + {Key key, this.onPressed, this.color, this.textColor, this.bigMode = false, this.iconSize, this.style}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + Color c = color ?? theme.accent1; + return TransparentBtn( + bigMode: bigMode, + child: Row( + children: [ + StyledImageIcon(icon, size: iconSize ?? 16, color: c), + HSpace(Insets.sm), + Text(label, style: style ?? TextStyles.Body1.textColor(textColor ?? c)), + HSpace(3), // Add a bit of extra padding to the right, seems like Icon() has it's own baked in padding + ], + ), + onPressed: onPressed, + ); + } +} diff --git a/flokk_src/lib/styled_components/clickable_icon_row.dart b/flokk_src/lib/styled_components/clickable_icon_row.dart new file mode 100644 index 0000000..b242f7f --- /dev/null +++ b/flokk_src/lib/styled_components/clickable_icon_row.dart @@ -0,0 +1,146 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/clickable_text.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class ClickableIconRow extends StatefulWidget { + final Function(String) onPressed; + final Function() onEditPressed; + final AssetImage icon; + final String value; + final String label; + final double size; + final Color iconColor; + final String editType; + + const ClickableIconRow( + {Key key, + this.icon, + this.value, + this.label, + this.onPressed, + this.size, + this.iconColor, + this.onEditPressed, + this.editType}) + : super(key: key); + + @override + _ClickableIconRowState createState() => _ClickableIconRowState(); +} + +class _ClickableIconRowState extends State { + bool get isMouseOver => _isMouseOver; + bool _isMouseOver = false; + + set isMouseOver(bool value) => setState(() => _isMouseOver = value); + + void _handleEditPressed() => context.read().editSelectedContact(widget.editType); + void _handleCopyPressed() => Clipboard.setData(ClipboardData(text: widget.value)); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + Color overColor = theme.isDark? ColorUtils.shiftHsl(theme.bg1, .2) : theme.bg2.withOpacity(.35); + return MouseRegion( + onEnter: (_) => isMouseOver = true, + onExit: (_) => isMouseOver = false, + child: Container( + decoration: BoxDecoration( + borderRadius: Corners.s5Border, + color: isMouseOver ? overColor : Colors.transparent, + ), + padding: EdgeInsets.symmetric(horizontal: Insets.l, vertical: Insets.m), + child: Stack( + overflow: Overflow.visible, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StyledImageIcon(widget.icon ?? null, size: (widget.size ?? 20), color: widget.iconColor ?? theme.grey), + SizedBox(width: Insets.l), + // Wrap value in ClickableText widget, it will get colored if anyone is listening + //Text(value), + ClickableText(widget.value, onPressed: widget.onPressed).constrained(maxWidth: 300).flexible(), + SizedBox(width: Insets.m), + if (widget.label != null) + Text(widget.label.toUpperCase(), style: TextStyles.Caption.textColor(theme.greyWeak)) + .translate(offset: Offset(0, 8)), + ], + ).padding(right: Insets.l * 1.5), + if (isMouseOver) + Positioned.fill( + child: Container( + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ColorShiftIconBtn( + StyledIcons.copy, + color: theme.accent1, + padding: EdgeInsets.zero, + onPressed: _handleCopyPressed, + ), + ColorShiftIconBtn( + StyledIcons.edit, + color: theme.accent1, + padding: EdgeInsets.zero, + onPressed: _handleEditPressed, + ), + + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +typedef Widget SeparatorBuilder(); + +class MultilineClickableIconRow extends StatelessWidget { + final Function(String) onPressed; + final AssetImage icon; + final List> rows; + final SeparatorBuilder separator; + final double size; + final Color iconColor; + final String editType; + + const MultilineClickableIconRow( + {Key key, this.icon, this.rows, this.onPressed, this.separator, this.size, this.iconColor, this.editType}) + : super(key: key); + + @override + Widget build(BuildContext context) { + List kids = []; + for (var i = 0; i < rows.length; i++) { + kids.add( + ClickableIconRow( + + /// Only show icon for the first row + icon: i == 0 ? icon : null, + onPressed: onPressed, + editType: editType, + value: rows[i].item1, + label: rows[i].item2, + size: size, + iconColor: iconColor), + ); + } + return SeparatedColumn(children: kids, separatorBuilder: separator); + } +} diff --git a/flokk_src/lib/styled_components/clickable_text.dart b/flokk_src/lib/styled_components/clickable_text.dart new file mode 100644 index 0000000..5631c79 --- /dev/null +++ b/flokk_src/lib/styled_components/clickable_text.dart @@ -0,0 +1,37 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:provider/provider.dart'; + +class ClickableText extends StatelessWidget { + final Function(String) onPressed; + final String text; + final TextStyle style; + final Color linkColor; + final bool underline; + + const ClickableText(this.text, {Key key, this.onPressed, this.style, this.underline = false, this.linkColor}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + var ts = (style ?? TextStyles.Body1.textHeight(1.5)); + Widget t = Text( + text ?? "", + style: style ?? (underline ? ts.underline : ts), + overflow: TextOverflow.clip, + ); + if (onPressed != null) { + /// Add tap handlers and style text + t = (t as Text).textColor(linkColor ?? theme.accent1).clickable( + () => onPressed?.call(text), + ); + } + return t.translate(offset: Offset(0, 0)); + } +} + diff --git a/flokk_src/lib/styled_components/design_grid.dart b/flokk_src/lib/styled_components/design_grid.dart new file mode 100644 index 0000000..a2061ea --- /dev/null +++ b/flokk_src/lib/styled_components/design_grid.dart @@ -0,0 +1,45 @@ +import 'package:flokk/_internal/components/design_grid_overlay.dart'; +import 'package:flokk/styles.dart'; +import 'package:flutter/material.dart'; + +class StyledDesignGrid extends StatelessWidget { + final Widget child; + final Alignment alignment; + final bool isEnabled; + + const StyledDesignGrid({Key key, this.child, this.alignment, this.isEnabled = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DesignGridOverlay( + alignment: alignment, + isEnabled: isEnabled, + grids: [ + GridLayout( + breakPt: PageBreaks.TabletPortrait, + gutters: EdgeInsets.only(left: 48, right: 0), + numCols: 6, + padding: Insets.lGutter, + ), + GridLayout( + breakPt: PageBreaks.TabletLandscape, + gutters: EdgeInsets.only(left: Sizes.sideBarSm), + numCols: 8, + padding: Insets.lGutter, + ), + GridLayout( + breakPt: PageBreaks.Desktop, + gutters: EdgeInsets.only(left: Sizes.sideBarMed), + numCols: 12, + padding: Insets.lGutter, + ), + GridLayout( + breakPt: double.infinity, + gutters: EdgeInsets.only(left: Sizes.sideBarLg), + numCols: 12, + padding: Insets.lGutter, + ), + ], + child: child); + } +} diff --git a/flokk_src/lib/styled_components/flokk_logo.dart b/flokk_src/lib/styled_components/flokk_logo.dart new file mode 100644 index 0000000..d077af9 --- /dev/null +++ b/flokk_src/lib/styled_components/flokk_logo.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class FlokkLogo extends StatelessWidget { + final double size; + final Color color; + + const FlokkLogo(this.size, this.color, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Image.asset("assets/images/flokk-logo.png", color: color ?? Colors.grey, height: size); + } +} + +class FlokkSidebarLogo extends StatelessWidget { + final bool skinny; + + const FlokkSidebarLogo(this.skinny, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: skinny ? 140 : 240, + child: Stack( + overflow: Overflow.visible, + children: [ + Image.asset("assets/images/sidebar-logo.png", width: skinny ? 140 : 160, filterQuality: FilterQuality.high), + if (!skinny) ...{ + Positioned( + left: 160, + top: 13, + child: Image.asset("assets/images/sidebar-bg.png", width: 84, filterQuality: FilterQuality.high), + ), + }, + ], + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/opening_divider.dart b/flokk_src/lib/styled_components/opening_divider.dart new file mode 100644 index 0000000..a925d85 --- /dev/null +++ b/flokk_src/lib/styled_components/opening_divider.dart @@ -0,0 +1,31 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class OpeningDivider extends StatelessWidget { + final bool isOpen; + final Color openColor; + final Color closeColor; + + const OpeningDivider({Key key, this.isOpen, this.openColor, this.closeColor}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: isOpen ? 1 : 0), + duration: isOpen ? .45.seconds : .15.seconds, + curve: isOpen ? Curves.easeIn : Curves.easeOut, + builder: (_, value, __) { + Color oColor = openColor ?? theme.accent1.withOpacity(.3); + Color cColor = closeColor ?? theme.greyWeak.withOpacity(.3); + return FractionallySizedBox( + alignment: Alignment.topLeft, + widthFactor: value, + child: Container(color: Color.lerp(cColor, oColor, value), height: 1), + ); + }, + ); + } +} diff --git a/flokk_src/lib/styled_components/scrolling/styled_horizontal_scroll_view.dart b/flokk_src/lib/styled_components/scrolling/styled_horizontal_scroll_view.dart new file mode 100644 index 0000000..146970e --- /dev/null +++ b/flokk_src/lib/styled_components/scrolling/styled_horizontal_scroll_view.dart @@ -0,0 +1,74 @@ +import 'package:flokk/_internal/utils/build_utils.dart'; +import 'package:flutter/material.dart'; + +class StyledHorizontalScrollView extends StatefulWidget { + final Duration autoScrollDuration; + final Curve autoScrollCurve; + final Widget child; + + StyledHorizontalScrollView({this.autoScrollDuration, this.autoScrollCurve, this.child, Key key}) : super(key: key); + + @override + State createState() => _StyledHorizontalScrollViewState(); +} + +class _StyledHorizontalScrollViewState extends State { + GlobalKey _childContainerKey; + GlobalKey _scrollViewKey; + double _childWidth = 0.0; + double _scrollWidth = 0.0; + ScrollController _scrollController; + + @override + void initState() { + _childContainerKey = GlobalKey(); + _scrollViewKey = GlobalKey(); + _scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + set childWidth(double width) { + if (width != _childWidth) { + // NOTE: CE: We are not setting the state here because we don't want to trigger a rebuild of the widget + // _childWidth is not used for building, only as a cache value + _childWidth = width; + scrollQueryToEnd(); + } + } + + set scrollWidth(double width) { + if (width != _scrollWidth) { + // NOTE: CE: We are not setting the state here because we don't want to trigger a rebuild of the widget + // _scrollWidth is not used for building, only as a cache value + _scrollWidth = width; + scrollQueryToEnd(); + } + } + + void scrollQueryToEnd() { + if (_scrollController.hasClients) { + _scrollController.animateTo(_scrollController.position.maxScrollExtent, + duration: widget.autoScrollDuration, curve: widget.autoScrollCurve); + } + } + + @override + Widget build(BuildContext context) { + /// Hook into these sub-widgets and rebuild once they callback with their current size + BuildUtils.getFutureSizeFromGlobalKey(_childContainerKey, (size) => childWidth = size.width); + BuildUtils.getFutureSizeFromGlobalKey(_scrollViewKey, (size) => scrollWidth = size.width); + + return SingleChildScrollView( + key: _scrollViewKey, + scrollDirection: Axis.horizontal, + controller: _scrollController, + child: Container(key: _childContainerKey, child: widget.child), + ); + } +} diff --git a/flokk_src/lib/styled_components/scrolling/styled_listview.dart b/flokk_src/lib/styled_components/scrolling/styled_listview.dart new file mode 100644 index 0000000..d6e1d6b --- /dev/null +++ b/flokk_src/lib/styled_components/scrolling/styled_listview.dart @@ -0,0 +1,120 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/scrolling/styled_scrollbar.dart'; +import 'package:flokk/styled_components/styled_card.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +typedef IndexedWidgetBuilder(BuildContext context, int index); + +class StyledScrollPhysics extends AlwaysScrollableScrollPhysics {} + +/// Core ListView for the app. +/// Wraps a [ScrollbarListStack] + [ListView.builder] and assigns the 'Styled' scroll physics for the app +/// Exposes a controller so other widgets can manipulate the list +class StyledListView extends StatefulWidget { + final double itemExtent; + final int itemCount; + final Axis axis; + final EdgeInsets padding; + final EdgeInsets scrollbarPadding; + final double barSize; + + final IndexedWidgetBuilder itemBuilder; + + StyledListView({ + Key key, + @required this.itemBuilder, + @required this.itemCount, + this.itemExtent, + this.axis = Axis.vertical, this.padding, this.barSize, this.scrollbarPadding, + }) : super(key: key) { + assert(itemExtent != 0, "Item extent should never be 0, null is ok."); + } + + @override + StyledListViewState createState() => StyledListViewState(); +} + +/// State is public so this can easily be controlled externally +class StyledListViewState extends State { + ScrollController scrollController; + + @override + void initState() { + scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(StyledListView oldWidget) { + if (oldWidget.itemCount != widget.itemCount || oldWidget.itemExtent != widget.itemExtent) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + double contentSize = (widget.itemCount ?? 0.0) * (widget.itemExtent ?? 00.0); + Widget listContent = ScrollbarListStack( + contentSize: contentSize, + axis: widget.axis, + controller: scrollController, + barSize: widget.barSize ?? 12, + scrollbarPadding: widget.scrollbarPadding, + child: ListView.builder( + padding: widget.padding, + scrollDirection: widget.axis, + physics: StyledScrollPhysics(), + controller: scrollController, + itemExtent: widget.itemExtent, + itemCount: widget.itemCount, + itemBuilder: (c, i) => widget.itemBuilder(c, i), + ), + ); + return listContent; + } +} + +class StyledListViewWithTitle extends StatelessWidget { + final Color bgColor; + final String title; + final AssetImage icon; + final List listItems; + + const StyledListViewWithTitle({Key key, this.bgColor, this.title, this.listItems, this.icon}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return StyledCard( + bgColor: bgColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...{ + StyledImageIcon(icon, color: theme.accent1Darker), + HSpace(Insets.sm), + }, + Text(title, style: TextStyles.T2.textColor(theme.accent1Darker)), + ], + ), + VSpace(Insets.sm), + StyledListView(itemCount: listItems.length, itemBuilder: (_, i) => listItems[i]).flexible() + ], + ).padding(left: Insets.l * .75, right: Insets.m, vertical: Insets.m), + ); + } +} diff --git a/flokk_src/lib/styled_components/scrolling/styled_scrollbar.dart b/flokk_src/lib/styled_components/scrolling/styled_scrollbar.dart new file mode 100644 index 0000000..6c1af53 --- /dev/null +++ b/flokk_src/lib/styled_components/scrolling/styled_scrollbar.dart @@ -0,0 +1,201 @@ +import 'dart:math'; + +import 'package:flokk/_internal/components/mouse_hover_builder.dart'; +import 'package:flokk/_internal/components/simple_value_notifier.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class StyledScrollbar extends StatefulWidget { + final double size; + final Axis axis; + final ScrollController controller; + final Function(double) onDrag; + final bool showTrack; + final Color handleColor; + final Color trackColor; + + // TODO: Remove contentHeight if we can fix this issue + // https://stackoverflow.com/questions/60855712/flutter-how-to-force-scrollcontroller-to-recalculate-position-maxextents + final double contentSize; + + const StyledScrollbar( + {Key key, this.size, this.axis, this.controller, this.onDrag, this.contentSize, this.showTrack = false, this.handleColor, this.trackColor}) + : super(key: key); + + @override + ScrollbarState createState() => ScrollbarState(); +} + +class ScrollbarState extends State { + double _viewExtent = 100; + SimpleNotifier buildNotifier = SimpleNotifier(); + + @override + void initState() { + widget.controller.addListener(() => setState(() {})); + super.initState(); + } + + @override + void didUpdateWidget(StyledScrollbar oldWidget) { + if (oldWidget.contentSize != widget.contentSize) setState(() {}); + super.didUpdateWidget(oldWidget); + } + + +// void calculateSize() { +// //[SB] Only hack I can find to make the ScrollController update it's maxExtents. +// //Call this whenever the content changes, so the scrollbar can recalculate it's size +// widget.controller.jumpTo(widget.controller.position.pixels + 1); +// Future.microtask(() => widget.controller +// .animateTo(widget.controller.position.pixels - 1, duration: 100.milliseconds, curve: Curves.linear)); +// } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return LayoutBuilder( + builder: (_, BoxConstraints constraints) { + double maxExtent; + switch (widget.axis) { + case Axis.vertical: + // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents + maxExtent = (widget.contentSize != null && widget.contentSize > 0) + ? widget.contentSize - constraints.maxHeight + : widget.controller?.position?.maxScrollExtent ?? 0; + _viewExtent = constraints.maxHeight; + + break; + case Axis.horizontal: + // Use supplied contentSize if we have it, otherwise just fallback to maxScrollExtents + maxExtent = (widget.contentSize != null && widget.contentSize > 0) + ? widget.contentSize - constraints.maxWidth + : widget.controller?.position?.maxScrollExtent ?? 0; + _viewExtent = constraints.maxWidth; + + break; + } + + double contentExtent = maxExtent + _viewExtent; + // Calculate the alignment for the handle, this is a value between 0 and 1, + // it automatically takes the handle size into acct + double handleAlignment = maxExtent == 0 ? 0 : widget.controller.offset / maxExtent; + + // Convert handle alignment from [0, 1] to [-1, 1] + handleAlignment *= 2.0; + handleAlignment -= 1.0; + + // Calculate handleSize by comparing the total content size to our viewport + double handleExtent = _viewExtent; + if (contentExtent > _viewExtent) { + //Make sure handle is never small than the minSize + handleExtent = max(60, _viewExtent * _viewExtent / contentExtent); + } + // Hide the handle if content is < the viewExtent + bool showHandle = contentExtent > _viewExtent && contentExtent > 0; + // Handle color + Color handleColor = widget.handleColor ?? ( + theme.isDark ? theme.greyWeak.withOpacity(.2) : theme.greyWeak); + // Track color + Color trackColor = widget.trackColor ?? ( + theme.isDark ? theme.greyWeak.withOpacity(.1) : theme.greyWeak.withOpacity(.3)); + + //Layout the stack, it just contains a child, and + return Stack(children: [ + /// TRACK, thin strip, aligned along the end of the parent + if (widget.showTrack) + Align( + alignment: Alignment(1, 1), + child: Container( + color: trackColor, + width: widget.axis == Axis.vertical ? widget.size : double.infinity, + height: widget.axis == Axis.horizontal ? widget.size : double.infinity, + ), + ), + + /// HANDLE - Clickable shape that changes scrollController when dragged + Align( + // Use calculated alignment to position handle from -1 to 1, let Alignment do the rest of the work + alignment: Alignment( + widget.axis == Axis.vertical ? 1 : handleAlignment, + widget.axis == Axis.horizontal ? 1 : handleAlignment, + ), + child: GestureDetector( + onVerticalDragUpdate: _handleVerticalDrag, + onHorizontalDragUpdate: _handleHorizontalDrag, + // HANDLE SHAPE + child: MouseHoverBuilder( + builder: (_, isHovered) => Container( + width: widget.axis == Axis.vertical ? widget.size : handleExtent, + height: widget.axis == Axis.horizontal ? widget.size : handleExtent, + decoration: BoxDecoration(color: handleColor.withOpacity(isHovered? 1 : .85), borderRadius: Corners.s3Border), + ), + ), + ), + ) + ]).opacity(showHandle ? 1.0 : 0.0, animate: false); + }, + ); + } + + void _handleHorizontalDrag(DragUpdateDetails details) { + double pos = widget.controller.offset; + double pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / _viewExtent; + widget.controller.jumpTo((pos + details.delta.dx * pxRatio).clamp(0.0, widget.controller.position.maxScrollExtent)); + widget.onDrag?.call(details.delta.dx); + } + + void _handleVerticalDrag(DragUpdateDetails details) { + double pos = widget.controller.offset; + double pxRatio = (widget.controller.position.maxScrollExtent + _viewExtent) / _viewExtent; + widget.controller.jumpTo((pos + details.delta.dy * pxRatio).clamp(0.0, widget.controller.position.maxScrollExtent)); + widget.onDrag?.call(details.delta.dy); + } +} + +class ScrollbarListStack extends StatelessWidget { + final double barSize; + final Axis axis; + final ChangeNotifier rebuildNotifier; + final Widget child; + final ScrollController controller; + final double contentSize; + final EdgeInsets scrollbarPadding; + final Color handleColor; + final Color trackColor; + + const ScrollbarListStack( + {Key key, this.barSize, this.axis, this.rebuildNotifier, this.child, this.controller, this.contentSize, this.scrollbarPadding, this.handleColor, this.trackColor}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + /// LIST + /// Wrap with a bit of padding on the right + child.padding( + right: axis == Axis.vertical ? barSize + Insets.sm : 0, + bottom: axis == Axis.horizontal ? barSize + Insets.sm : 0, + ), + + /// SCROLLBAR + Padding( + padding: scrollbarPadding ?? EdgeInsets.zero, + child: StyledScrollbar( + size: barSize, + axis: axis, + controller: controller, + contentSize: contentSize, + trackColor: trackColor, + handleColor: handleColor, + showTrack: true, + ), + ), + ], + ); + } +} diff --git a/flokk_src/lib/styled_components/scrolling/styled_scrollview.dart b/flokk_src/lib/styled_components/scrolling/styled_scrollview.dart new file mode 100644 index 0000000..cff44f9 --- /dev/null +++ b/flokk_src/lib/styled_components/scrolling/styled_scrollview.dart @@ -0,0 +1,65 @@ +import 'package:flokk/styled_components/scrolling/styled_scrollbar.dart'; +import 'package:flutter/material.dart'; + +import 'styled_listview.dart'; + +class StyledScrollView extends StatefulWidget { + final double contentSize; + final Axis axis; + final Color trackColor; + final Color handleColor; + + final Widget child; + + StyledScrollView({ + Key key, + @required this.child, + this.contentSize, + this.axis = Axis.vertical, this.trackColor, this.handleColor, + }) : super(key: key) {} + + @override + _StyledScrollViewState createState() => _StyledScrollViewState(); +} + +class _StyledScrollViewState extends State { + ScrollController scrollController; + + @override + void initState() { + scrollController = ScrollController(); + super.initState(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(StyledScrollView oldWidget) { + if (oldWidget.child != widget.child) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return ScrollbarListStack( + contentSize: widget.contentSize, + axis: widget.axis, + controller: scrollController, + barSize: 12, + trackColor: widget.trackColor, + handleColor: widget.handleColor, + child: SingleChildScrollView( + scrollDirection: widget.axis, + physics: StyledScrollPhysics(), + controller: scrollController, + child: widget.child, + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/social/clickable_social_badges.dart b/flokk_src/lib/styled_components/social/clickable_social_badges.dart new file mode 100644 index 0000000..580d832 --- /dev/null +++ b/flokk_src/lib/styled_components/social/clickable_social_badges.dart @@ -0,0 +1,150 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/git_event_data.dart'; +import 'package:flokk/data/social_activity_type.dart'; +import 'package:flokk/data/social_contact_data.dart'; +import 'package:flokk/data/tweet_data.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/models/github_model.dart'; +import 'package:flokk/models/twitter_model.dart'; +import 'package:flokk/styled_components/social/social_badge.dart'; +import 'package:flokk/styled_components/social/social_popup_form.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:timeago/timeago.dart' as timeago; + +import '../styled_icons.dart'; + +class ClickableSocialBadges extends StatefulWidget { + final ContactData contact; + final bool showTimeSince; + + const ClickableSocialBadges(this.contact, {Key key, this.showTimeSince = false}) : super(key: key); + + @override + _ClickableSocialBadgesState createState() => _ClickableSocialBadgesState(); +} + +class _ClickableSocialBadgesState extends State { + LayerLink overlayLink = LayerLink(); + + Size _viewSize; + + void _handleSocialClicked(BuildContext context, ContactData contact, SocialActivityType type) { + // If they clicked a badge that they have already entered a handle for, then open their social panel. + if (contact.hasSocialOfType(type)) { + context.read().trySetSelectedContact(contact, showSocial: true); + } else { + _showSocialMiniFormOverlay(context, type); + } + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + //Bind to social models so we always rebuild when they change + context.watch(); + context.watch(); + //Fetch model so we can get the latest social info + ContactsModel contactsModel = Provider.of(context, listen: false); + // Grab socialData for this contact (might be null) + SocialContactData social = contactsModel.getSocialById(widget.contact.id); + // Get the time of their last activity + DateTime lastSocialTime = social?.latestActivity?.createdAt; + // Grab any tweets we haven't look at yet + List newTweets = social?.newTweets ?? []; + List newGits = social?.newGits ?? []; + // Figure out bottom text, changes if we have no social + String bottomTxt = "Add Social IDs"; + if (widget.contact.hasAnySocial) { + bottomTxt = lastSocialTime != null ? timeago.format(lastSocialTime) : "No New Activities"; + } + return LayoutBuilder( + builder: (_, constraints) { + // Record the current view size so the Overlay knows how much to offset itself by + _viewSize = Size(constraints.maxWidth, constraints.maxHeight); + return CompositedTransformTarget( + link: overlayLink, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SocialBadge( + icon: StyledIcons.twitterActive, + iconPlaceholder: StyledIcons.twitterEmpty, + newMessageCount: newTweets.length, + hasAccount: widget.contact.hasTwitter, + onPressed: () => _handleSocialClicked(context, widget.contact, SocialActivityType.Twitter), + ), + HSpace(Insets.m), + SocialBadge( + icon: StyledIcons.githubActive, + iconPlaceholder: StyledIcons.githubEmpty, + newMessageCount: newGits.length, + hasAccount: widget.contact.hasGit, + onPressed: () => _handleSocialClicked(context, widget.contact, SocialActivityType.Git), + ), + ], + ), + if (widget.showTimeSince) VSpace(Insets.sm * 1.5), + if (widget.showTimeSince) Text(bottomTxt, style: TextStyles.Body2.textColor(theme.greyWeak)), + VSpace(Insets.sm), + ]), + ); + }, + ); + } + + void _showSocialMiniFormOverlay(BuildContext context, SocialActivityType type) { + AppTheme theme = context.read(); + OverlayEntry bg; + OverlayEntry form; + + void _closeOverlay() { + bg.remove(); + form.remove(); + } + + bg = OverlayEntry( + builder: (_) { + return FadeInWidget( + Container(color: theme.greyWeak.withOpacity(.6)).gestures(onTap: _closeOverlay), + ); + }, + ); + form = OverlayEntry(builder: (_) { + return CompositedTransformFollower( + offset: Offset(-SocialPopupForm.kWidth * .5 + _viewSize.width * .5, 0), + link: overlayLink, + child: FadeInWidget(SocialPopupForm( + widget.contact, + onClosePressed: _closeOverlay, + socialActivityType: type, + ))); + }); + Overlay.of(context).insert(bg); + Overlay.of(context).insert(form); + } +} + +class FadeInWidget extends StatelessWidget { + final Widget child; + + const FadeInWidget(this.child, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + duration: Durations.fastest, + builder: (_, value, child) => Opacity(opacity: value, child: child), + child: child, + ); + } +} diff --git a/flokk_src/lib/styled_components/social/git_item_renderer.dart b/flokk_src/lib/styled_components/social/git_item_renderer.dart new file mode 100644 index 0000000..2a335a3 --- /dev/null +++ b/flokk_src/lib/styled_components/social/git_item_renderer.dart @@ -0,0 +1,184 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/url_launcher/url_launcher.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/git_event_data.dart'; +import 'package:flokk/data/git_repo_data.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:github/github.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import '../styled_icons.dart'; + +class GitUtils { + static DateFormat get monthDayFmt => DateFormat("MMM d"); + + //EC: https://developer.github.com/v3/activity/events/types/, made human readable and added past tense where applicable + static String getStringForType(String type) { + if (type == "CheckRunEvent") return "Checked Run"; + if (type == "CheckSuiteEvent") return "Checked Suite"; + if (type == "CommitCommentEvent") return "Commit Commented"; + if (type == "ContentReferenceEvent") return "Content Referenced"; + if (type == "CreateEvent") return "Created"; + if (type == "DeleteEvent") return "Deleted"; + if (type == "DeployKeyEvent") return "Deployed Key"; + if (type == "DeploymentEvent") return "Deployed"; + if (type == "DeploymentStatusEvent") return "Deployment Status"; + if (type == "DownloadEvent") return "Downloaded"; + if (type == "FollowEvent") return "Followed"; + if (type == "ForkEvent") return "Forked"; + if (type == "ForkApplyEvent") return "Applied Fork"; + if (type == "GitHubAppAuthorizationEvent") return "GitHub AppAuthorization"; + if (type == "GistEvent") return "Gist"; + if (type == "GollumEvent") return "Gollum"; + if (type == "InstallationEvent") return "Installation"; + if (type == "InstallationRepositoriesEvent") return "Installation Repositories"; + if (type == "IssueCommentEvent") return "Commented on Issue"; + if (type == "IssuesEvent") return "Issues"; + if (type == "LabelEvent") return "Labelled"; + if (type == "MarketplacePurchaseEvent") return "Marketplace Purchase"; + if (type == "MemberEvent") return "Member"; + if (type == "MembershipEvent") return "Membership"; + if (type == "MetaEvent") return "Meta"; + if (type == "MilestoneEvent") return "Milestone"; + if (type == "OrganizationEvent") return "Organization"; + if (type == "OrgBlockEvent") return "Org Blocked"; + if (type == "PackageEvent") return "Packaged"; + if (type == "PageBuildEvent") return "PageBuild"; + if (type == "ProjectCardEvent") return "Project Card"; + if (type == "ProjectColumnEvent") return "Project Column"; + if (type == "ProjectEvent") return "Project"; + if (type == "PublicEvent") return "Public"; + if (type == "PullRequestEvent") return "Pull Requested"; + if (type == "PullRequestReviewEvent") return "Reviewed Pull Request"; + if (type == "PullRequestReviewCommentEvent") return "Commented Pull Request Review"; + if (type == "PushEvent") return "Pushed"; + if (type == "ReleaseEvent") return "Released"; + if (type == "RepositoryDispatchEvent") return "Repository Dispatch"; + if (type == "RepositoryEvent") return "Repository"; + if (type == "RepositoryImportEvent") return "Repository Import"; + if (type == "RepositoryVulnerabilityAlertEvent") return "Repository Vulnerability Alert"; + if (type == "SecurityAdvisoryEvent") return "Security Advisory"; + if (type == "SponsorshipEvent") return "Sponsorship"; + if (type == "StarEvent") return "Starred"; + if (type == "StatusEvent") return "Status"; + if (type == "TeamEvent") return "Team"; + if (type == "TeamAddEvent") return "Team Added"; + if (type == "WatchEvent") return "Watched"; + return type; + } +} + +/// Item Renderer for Git Events +class GitEventListItem extends StatelessWidget { + final GitEvent gitEvent; + + const GitEventListItem(this.gitEvent, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + TextStyle titleStyle = TextStyles.Body2; + return Column( + children: [ + Row(children: [ + Text("${gitEvent.event.actor.login}", style: titleStyle.bold), + Text( + " · ${GitUtils.getStringForType(gitEvent.event.type)} · ${GitUtils.monthDayFmt.format(gitEvent.createdAt)}", + style: titleStyle), + ]), + VSpace(Insets.xs * 1.5), + GitRepoInfo(gitEvent.repository), + VSpace(Insets.l), + ], + ); + } +} + +/// Item Renderer for Git Repos +class GitRepoListItem extends StatelessWidget { + final GitRepo repo; + + const GitRepoListItem(this.repo, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + TextStyle titleStyle = TextStyles.Body2; + return Column( + children: [ + Row(children: [ + Text("${repo.contacts?.first?.nameGiven ?? "?"}", style: titleStyle.bold), + Text(" · ${GitUtils.monthDayFmt.format(repo.repository.updatedAt)}", style: titleStyle), + ]), + VSpace(Insets.xs * 1.5), + GitRepoInfo(repo.repository), + VSpace(Insets.l), + ], + ); + } +} + +/// Small pill used for Language Type +class _GitPill extends StatelessWidget { + final String label; + + const _GitPill(this.label, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return Text(label, style: TextStyles.Footnote.letterSpace(0)) + .padding(all: Insets.xs, horizontal: Insets.sm) + .decorated(color: theme.bg2, borderRadius: Corners.s3Border); + } +} + +/// This is used in both renderers to show the core Repo info +class GitRepoInfo extends StatelessWidget { + final Repository repo; + + const GitRepoInfo(this.repo, {Key key}) : super(key: key); + + void _handleRepoPressed() => UrlLauncher.openHttp(repo.htmlUrl); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + TextStyle smallTextStyle = TextStyles.Body3.textHeight(1.4); + TextStyle contentTextStyle = TextStyles.Body1.textHeight(1.4); + return Column( + children: [ + Row(children: [ + RichText( + text: TextSpan( + children: [ + TextSpan(text: "${repo?.name}", style: contentTextStyle.textColor(theme.accent1Dark)), + TextSpan(text: " | ${repo?.description}", style: contentTextStyle.textColor(theme.txt)), + ], + ), + ).flexible(), + //ClickableText(repo.name, onPressed: _handleRepoPressed,), + ]), + VSpace(Insets.sm * 1.5), + Row(children: [ + if (repo?.language != null) ...{ + _GitPill(repo?.language), + HSpace(Insets.sm), + }, + StyledImageIcon(StyledIcons.starFilled, size: 12, color: theme.grey), + Text("${repo?.stargazersCount ?? 0}", style: smallTextStyle).padding(left: Insets.xs), + HSpace(Insets.sm), + StyledImageIcon(StyledIcons.socialFork, size: 12, color: theme.grey).translate( + offset: Offset(0, 1), // Add a bit of offset to the fork icon cause it's a bit tall and doesn't look right + ), + Text("${repo?.forksCount ?? 0}", style: smallTextStyle).padding(left: Insets.xs), + ]), + VSpace(Insets.m * 1.5), + Container(color: theme.greyWeak.withOpacity(.35), width: double.infinity, height: 1), + //VSpace(Insets.l), + ], + ).gestures(onTap: _handleRepoPressed); + } +} diff --git a/flokk_src/lib/styled_components/social/social_badge.dart b/flokk_src/lib/styled_components/social/social_badge.dart new file mode 100644 index 0000000..19be968 --- /dev/null +++ b/flokk_src/lib/styled_components/social/social_badge.dart @@ -0,0 +1,51 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SocialBadge extends StatelessWidget { + final AssetImage icon; + final AssetImage iconPlaceholder; + final int newMessageCount; + final bool hasAccount; + final Function() onPressed; + + const SocialBadge( + {this.icon, this.iconPlaceholder, this.newMessageCount, Key key, this.hasAccount, this.onPressed}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + double size = 35; + String msgText = newMessageCount > 9 ? "9+" : "$newMessageCount"; + return TransparentBtn( + onPressed: onPressed, + child: Stack( + children: [ + /// PLACEHOLDER + if (!hasAccount) StyledImageIcon(iconPlaceholder, size: 32, color: theme.greyWeak.withOpacity(.7)).center(), + + /// VALID ACCOUNT + if (hasAccount) StyledImageIcon(icon, size: 28, color: newMessageCount > 0 ? theme.accent1 : theme.grey), + if (hasAccount) + StyledContainer( + theme.bg1, + align: Alignment.center, + borderRadius: BorderRadius.circular(99), + child: Text( + "$msgText", + textAlign: TextAlign.center, + style: TextStyles.Footnote.textColor(theme.txt).letterSpace(1), + ).translate(offset: Offset(0, -1)), + ).constrained(width: 19, height: 19).alignment(Alignment.bottomRight), + ], + ).width(size).height(size), + ); + } +} diff --git a/flokk_src/lib/styled_components/social/social_popup_form.dart b/flokk_src/lib/styled_components/social/social_popup_form.dart new file mode 100644 index 0000000..db18249 --- /dev/null +++ b/flokk_src/lib/styled_components/social/social_popup_form.dart @@ -0,0 +1,148 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/contacts/update_contact_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/social_activity_type.dart'; +import 'package:flokk/styled_components/buttons/primary_btn.dart'; +import 'package:flokk/styled_components/buttons/secondary_btn.dart'; +import 'package:flokk/styled_components/styled_card.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../styled_icons.dart'; + +class SocialPopupForm extends StatefulWidget { + static const double kWidth = 270; + static const double kHeight = 190; + + final void Function() onClosePressed; + final ContactData contact; + final SocialActivityType socialActivityType; + + const SocialPopupForm(this.contact, {Key key, this.onClosePressed, this.socialActivityType}) : super(key: key); + + @override + _SocialPopupFormState createState() => _SocialPopupFormState(); +} + +class _SocialPopupFormState extends State { + ContactData _tmpContact; + + void _handleGitChanged(String value) => _tmpContact.gitUsername = value; + + void _handleTwitterChanged(String value) => _tmpContact.twitterHandle = value; + + void _handleBtnPressed(bool doSave) { + if (doSave && !_tmpContact.hasSameSocial(widget.contact)) { + UpdateContactCommand(context).execute(_tmpContact, updateSocial: true); + } + widget.onClosePressed?.call(); + } + + @override + void initState() { + _tmpContact = widget.contact.copy(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Material( + type: MaterialType.transparency, + child: StyledCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + /// GIT ICON + TEXT + _SocialTextInput( + icon: StyledIcons.githubActive, + hint: "github.com/", + initial: widget.contact.gitUsername, + onChanged: _handleGitChanged, + autoFocus: widget.socialActivityType == SocialActivityType.Git, + ), + VSpace(Insets.sm), + + /// TWITTER ICON + TEXT + _SocialTextInput( + icon: StyledIcons.twitterActive, + hint: "@", + initial: widget.contact.twitterHandle, + onChanged: _handleTwitterChanged, + autoFocus: widget.socialActivityType == SocialActivityType.Twitter, + ), + VSpace(Insets.l), + + /// SUBMIT BUTTONS + Row( + children: [ + PrimaryTextBtn("SAVE", onPressed: () => _handleBtnPressed(true)), + HSpace(Insets.m), + SecondaryTextBtn("CANCEL", onPressed: () => _handleBtnPressed(false)), + ], + ), + ], + ).padding(all: Insets.l).constrained(width: SocialPopupForm.kWidth), + ), + ) + ], + ); + } +} + +class _SocialTextInput extends StatelessWidget { + final String hint; + final String initial; + final AssetImage icon; + final bool autoFocus; + final void Function(String) onChanged; + + const _SocialTextInput({Key key, this.hint, this.onChanged, this.initial, this.icon, this.autoFocus=false}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + double prefixSize = StringUtils.measure(hint, TextStyles.Body1).width; + EdgeInsets padding = StyledFormTextInput.kDefaultTextInputPadding.copyWith(left: prefixSize + .5); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + StyledImageIcon(icon, color: ColorUtils.shiftHsl(theme.accent1, theme.isDark ? .2 : -.2), size: 30), + HSpace(Insets.m), + Stack( + children: [ + /// Prefix text, non-interactive + FocusScope( + canRequestFocus: false, + child: IgnorePointer(child: buildTextInput(context, hint: hint, onChanged: (v) {}))), + + /// Value text + buildTextInput(context, + hint: "", initial: initial, onChanged: onChanged, autoFocus: autoFocus, padding: padding), + ], + ).flexible() + ], + ); + } + + buildTextInput(BuildContext context, + {String hint, String initial = "", bool autoFocus = false, void Function(String) onChanged, EdgeInsets padding}) { + return StyledFormTextInput( + contentPadding: padding, + hintText: hint, + autoFocus: autoFocus, + initialValue: initial, + maxLines: 1, + onChanged: onChanged); + } +} diff --git a/flokk_src/lib/styled_components/social/tweet_item_renderer.dart b/flokk_src/lib/styled_components/social/tweet_item_renderer.dart new file mode 100644 index 0000000..decaaaf --- /dev/null +++ b/flokk_src/lib/styled_components/social/tweet_item_renderer.dart @@ -0,0 +1,73 @@ +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/selectable_link_text.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/url_launcher/url_launcher.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/tweet_data.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../styled_icons.dart'; + +class TweetListItem extends StatelessWidget { + final Tweet tweet; + + const TweetListItem(this.tweet, {Key key}) : super(key: key); + + void _handleRowPressed() { + UrlLauncher.openHttp(tweet.url); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + int minutesAgo = DateTime.now().difference(tweet.createdAt).inMinutes; + String timeTxt = minutesAgo < 60 ? "${minutesAgo}m" : "${(minutesAgo / 60).round()}h"; + if (minutesAgo > 60 * 24) { + timeTxt = "${(minutesAgo / 60 / 24).round()}d"; + } + TextStyle titleStyle = TextStyles.Body2; + return Column( + children: [ + Row(children: [ + OneLineText(tweet.user.name, style: titleStyle.bold).flexible(), + HSpace(Insets.sm), + OneLineText(tweet.retweeted ? "Retweeted" : "Tweeted", style: titleStyle).flexible(), + Text(" · ", style: titleStyle), + Text(timeTxt, style: titleStyle), + ]), + VSpace(Insets.sm), + Row(children: [ + SelectableLinkText( + text: "${tweet.text}", + linkStyle: TextStyles.Body1.textHeight(1.6).textColor(theme.accent1), + textStyle: TextStyles.Body1.textHeight(1.6).textColor(theme.txt)) + .flexible() + ]), + VSpace(Insets.m), + Row(children: [ + StyledImageIcon(StyledIcons.socialLike, size: 12, color: theme.grey), + HSpace(Insets.sm), + Text( + "${tweet.favoriteCount}", + style: TextStyles.Body3.textColor(theme.grey), + ), + HSpace(Insets.m), + StyledImageIcon(StyledIcons.socialRetweet, size: 12, color: theme.grey), + HSpace(Insets.sm), + Text( + "${tweet.retweetCount}", + style: TextStyles.Body3.textColor(theme.grey), + ), + ]), + VSpace(Insets.m), + Container(color: theme.greyWeak.withOpacity(.35), width: double.infinity, height: 1), + VSpace(Insets.l), + ], + ).gestures(onTap: _handleRowPressed, behavior: HitTestBehavior.opaque); + } + +} diff --git a/flokk_src/lib/styled_components/styled_autocomplete_dropdown.dart b/flokk_src/lib/styled_components/styled_autocomplete_dropdown.dart new file mode 100644 index 0000000..cf514ff --- /dev/null +++ b/flokk_src/lib/styled_components/styled_autocomplete_dropdown.dart @@ -0,0 +1,230 @@ +import 'dart:math'; +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/_internal/utils/utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class StyledAutoCompleteDropdown extends StatefulWidget { + final String initialValue; + final String hint; + final List items; + final double maxHeight; + final Function(String) onChanged; + final Function(bool) onFocusChanged; + + const StyledAutoCompleteDropdown( + {Key key, this.initialValue, this.hint, this.items, this.onChanged, this.onFocusChanged, this.maxHeight = 500}) + : super(key: key); + + @override + _StyledAutoCompleteDropdownState createState() => _StyledAutoCompleteDropdownState(); +} + +class _StyledAutoCompleteDropdownState extends State { + bool _isOpen = false; + OverlayEntry _overlay; + ValueNotifier> _itemsFiltered; + TextEditingController _textController; + FocusNode _textFocusNode; + FocusScopeNode _dropDownFocusNode; + LayerLink layerLink = LayerLink(); + bool _skipNextFocusOut = false; + + String get currentText => _textController.text; + + @override + void initState() { + RawKeyboard.instance.addListener(_handleRawKeyPressed); + _itemsFiltered = ValueNotifier(widget.items); + _textController = TextEditingController(text: widget.initialValue); + _dropDownFocusNode = FocusScopeNode(); + _dropDownFocusNode.addListener(handleDropdownFocusChanged); + super.initState(); + } + + @override + void dispose() { + RawKeyboard.instance.removeListener(_handleRawKeyPressed); + // TODO: These dispose calls seem to break the contact edit menu + //_itemsFiltered.dispose(); + //_textController.dispose(); + //_dropDownFocusNode.dispose(); + super.dispose(); + } + + void _updateFilteredItems() { + _itemsFiltered.value = widget.items.where((i) => i.contains(currentText.toUpperCase())).toList(); + } + + void _handleArrowTap() { + if (!_isOpen) { + showOverlay(); + _textFocusNode?.requestFocus(); + } else { + showOverlay(false); + Utils.unFocus(); + } + } + + void _handleRawKeyPressed(RawKeyEvent evt) { + if (evt is RawKeyDownEvent) { + if (_textFocusNode.hasFocus && evt.logicalKey == LogicalKeyboardKey.arrowDown) { + _skipNextFocusOut = true; + Future.microtask(() => _dropDownFocusNode.requestFocus()); + } + if (_dropDownFocusNode.hasFocus && + (evt.logicalKey == LogicalKeyboardKey.arrowRight || evt.logicalKey == LogicalKeyboardKey.arrowLeft)) { + _textFocusNode.requestFocus(); + } + } + } + + void _handleFocusChanged(bool value) { + if (_skipNextFocusOut && !value) { + _skipNextFocusOut = false; + return; + } + showOverlay(value); + widget.onFocusChanged?.call(value); + } + + _handleFocusCreate(FocusNode focusNode) { + _textFocusNode = focusNode; + } + + void _handleValueChanged(String value) { + _updateFilteredItems(); + widget.onChanged?.call(value); + showOverlay(); + } + + void _handleItemSelected(String value) { + _textController.text = value; + _updateFilteredItems(); + widget.onChanged?.call(value); + showOverlay(false); + _textFocusNode?.requestFocus(); + //Utils.unFocus(); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + bool downArrow = _itemsFiltered.value.isNotEmpty && _isOpen; + + /// Wrap the dropdown content in a [CompositedTransformTarget] so the Overlay can easily position itself here. + return CompositedTransformTarget( + link: layerLink, + child: Stack( + children: [ + StyledFormTextInput( + capitalization: TextCapitalization.words, + contentPadding: EdgeInsets.only(right: 22, bottom: Insets.sm), + controller: _textController, + initialValue: widget.initialValue, + hintText: widget.hint, + maxLines: 1, + textStyle: TextStyles.Body2, + onFocusCreated: _handleFocusCreate, + onFocusChanged: _handleFocusChanged, + onChanged: _handleValueChanged), + StyledImageIcon(StyledIcons.dropdownClose, size: 12, color: theme.greyStrong) + .rotate(angle: downArrow ? 0 : pi, animate: true) + .animate(Durations.fast, Curves.easeOut) + .alignment(Alignment.topLeft) + .gestures(onTap: _handleArrowTap) + .positioned(right: 4, top: 4), + ], + ), + ); + } + + void showOverlay([bool show = true]) { + if (show && _overlay == null) { + _overlay = OverlayEntry(builder: (_) => _AutoCompleteDropdown(this, focusNode: _dropDownFocusNode)); + Overlay.of(context).insert(_overlay); + } else if (!show && _overlay != null) { + _overlay.remove(); + _overlay = null; + } + setState(() => _isOpen = show); + } + + void handleDropdownFocusChanged() { + if (!_dropDownFocusNode.hasFocus) { + showOverlay(false); + } + } +} + +class _AutoCompleteDropdown extends StatelessWidget { + final _StyledAutoCompleteDropdownState state; + final double rowHeight; + final FocusScopeNode focusNode; + + _AutoCompleteDropdown(this.state, {Key key, this.focusNode, this.rowHeight = 40}) : super(key: key); + + List get items => state.widget.items; + + List get filteredItems => state._itemsFiltered.value; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + if (state.context == null) return Container(); + RenderBox rb = state.context.findRenderObject(); + Size size = rb.size; + double longest = StringUtils.measureLongest(filteredItems, TextStyles.Caption, 50); + longest += Insets.m * 2; + double maxHeight = state.widget.maxHeight ?? 300; + + /// Use [CompositedTransformFollower] to link the overlay position to the original content. + /// Automatically updates when the window resizes or on scroll. + return CompositedTransformFollower( + showWhenUnlinked: false, + + /// Use a layerLink to connect to the CompositedTransformTarget + link: state.layerLink, + child: ValueListenableBuilder>( + valueListenable: state._itemsFiltered, + builder: (_, matches, __) { + return FocusScope( + node: focusNode, + child: Stack( + children: [ + ListView.builder( + itemExtent: rowHeight, + itemCount: matches.length, + itemBuilder: (_, index) { + return BaseStyledBtn( + contentPadding: EdgeInsets.symmetric(horizontal: Insets.m), + minHeight: rowHeight, + onPressed: () => state._handleItemSelected(matches[index]), + child: OneLineText( + "${matches[index].toUpperCase()}", + style: TextStyles.Caption.textColor(theme.greyWeak), + ).alignment(Alignment.centerLeft), + ); + }, + ) + .decorated(color: theme.surface, boxShadow: Shadows.m(theme.accent1)) + .constrained(width: max(longest, size.width), height: min(matches.length * rowHeight, maxHeight)) + .padding(top: 26) + //.positioned(left: pos.dx, top: pos.dy) + ], + ), + ); + }, + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_card.dart b/flokk_src/lib/styled_components/styled_card.dart new file mode 100644 index 0000000..cbdd913 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_card.dart @@ -0,0 +1,33 @@ +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// A card that defaults to theme.surface1, and has a built in shadow and rounded corners. +class StyledCard extends StatelessWidget { + final Color bgColor; + final bool enableShadow; + final Widget child; + final Function() onPressed; + final Alignment align; + + const StyledCard({Key key, this.bgColor, this.enableShadow = true, this.child, this.onPressed, this.align}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + Color c = bgColor ?? theme.surface; + + Widget content = StyledContainer(c, + align: align, + child: child, + borderRadius: Corners.s8Border, + shadows: enableShadow ? Shadows.m(theme.accent1Darker) : null); + + if (onPressed != null) return TransparentBtn(child: content, onPressed: onPressed); + return content; + } +} diff --git a/flokk_src/lib/styled_components/styled_checkbox.dart b/flokk_src/lib/styled_components/styled_checkbox.dart new file mode 100644 index 0000000..846eba1 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_checkbox.dart @@ -0,0 +1,50 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class StyledCheckbox extends StatelessWidget { + final bool value; + final double size; + final Function(bool) onChanged; + + const StyledCheckbox({Key key, this.value, this.size = 18, this.onChanged}) : super(key: key); + + void _handleTapUp(TapUpDetails details) { + if (value == true) { + onChanged(false); + } else if (value == false) { + onChanged(null); + } else if (value == null) { + onChanged(true); + } + } + + Widget _getIconForCurrentState() { + if (value == true) return StyledImageIcon(StyledIcons.checkboxSelected, color: Colors.white, size: 15); + if (value == null) return StyledImageIcon(StyledIcons.checkboxPartial, color: Colors.white, size: 15); + return Container(); + } + + Widget _wrapGestures(Widget child) { + if (onChanged == null) return child; + return child.gestures(onTapUp: _handleTapUp, behavior: HitTestBehavior.opaque); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: value == false ? Colors.transparent : theme.accent1Darker, + borderRadius: Corners.s3Border, + border: Border.all(color: value == false ? theme.grey : theme.accent1Darker, width: 1.5)), + child: _wrapGestures(_getIconForCurrentState()), + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_container.dart b/flokk_src/lib/styled_components/styled_container.dart new file mode 100644 index 0000000..ac4f92a --- /dev/null +++ b/flokk_src/lib/styled_components/styled_container.dart @@ -0,0 +1,42 @@ +import 'package:flokk/styles.dart'; +import 'package:flutter/material.dart'; + +/// A container that will animate when you change colors. +class StyledContainer extends StatelessWidget { + final Color color; + final BorderRadiusGeometry borderRadius; + final List shadows; + final Widget child; + final double width; + final double height; + final Alignment align; + final EdgeInsets margin; + final Duration duration; + final BoxBorder border; + + const StyledContainer(this.color, + {Key key, + this.borderRadius, + this.shadows, + this.child, + this.width, + this.height, + this.align, + this.margin, + this.duration, + this.border}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + width: width, + height: height, + child: child, + margin: margin, + alignment: align, + duration: duration ?? Durations.medium, + decoration: BoxDecoration( + color: color, borderRadius: borderRadius, boxShadow: shadows, border: border)); + } +} diff --git a/flokk_src/lib/styled_components/styled_dialogs.dart b/flokk_src/lib/styled_components/styled_dialogs.dart new file mode 100644 index 0000000..8bdab46 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_dialogs.dart @@ -0,0 +1,192 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/globals.dart'; +import 'package:flokk/styled_components/buttons/ok_cancel_btn_row.dart'; +import 'package:flokk/styled_components/scrolling/styled_listview.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Dialogs { + static Future show(Widget child, [BuildContext context]) async { + return await (context != null ? Navigator.of(context) : AppGlobals.nav).push( + StyledDialogRoute( + pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { + return SafeArea(child: child); + }, + ), + ); + /*return await showDialog( + context: context ?? MainViewContext.value, + builder: (context) => child, + );*/ + } +} + +class StyledDialog extends StatelessWidget { + final Widget child; + final double maxWidth; + final double maxHeight; + final EdgeInsets padding; + final EdgeInsets margin; + final BorderRadius borderRadius; + final Color bgColor; + final double elevation; + final bool shrinkWrap; + + const StyledDialog({ + Key key, + this.child, + this.maxWidth, + this.maxHeight, + this.padding, + this.margin, + this.bgColor, + this.borderRadius, + this.elevation, + this.shrinkWrap = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + BorderRadius radius = borderRadius ?? Corners.s8Border; + AppTheme theme = context.watch(); + + Widget innerContent = Container( + padding: padding ?? EdgeInsets.all(Insets.lGutter), + color: bgColor ?? theme.surface, + //elevation: elevation ?? dialogTheme.elevation ?? 3, + child: child, + ); + + if (shrinkWrap) { + innerContent = IntrinsicWidth(child: IntrinsicHeight(child: innerContent)); + } + + return FocusTraversalGroup( + child: Container( + margin: margin ?? EdgeInsets.all(Insets.lGutter * 2), + alignment: Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: 280.0, + maxHeight: maxHeight ?? double.infinity, + maxWidth: maxWidth ?? double.infinity, + ), + child: ClipRRect( + borderRadius: radius, + child: SingleChildScrollView( + physics: StyledScrollPhysics(), + child: Material(type: MaterialType.transparency, child: innerContent), + ), + ), + ), + ), + ); + } +} + +class OkCancelDialog extends StatelessWidget { + final Function() onOkPressed; + final Function() onCancelPressed; + final String okLabel; + final String cancelLabel; + final String title; + final String message; + final double maxWidth; + + const OkCancelDialog( + {Key key, + this.onOkPressed, + this.onCancelPressed, + this.okLabel, + this.cancelLabel, + this.title, + this.message, + this.maxWidth}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return StyledDialog( + maxWidth: maxWidth ?? 500, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Text(title.toUpperCase(), style: TextStyles.T1.textColor(theme.accent1Darker)), + VSpace(Insets.sm * 1.5), + Container(color: theme.greyWeak.withOpacity(.35), height: 1), + VSpace(Insets.m * 1.5), + ], + Text(message, style: TextStyles.Body1.textHeight(1.5)), + SizedBox(height: Insets.l), + OkCancelBtnRow( + onOkPressed: onOkPressed, + onCancelPressed: onCancelPressed, + okLabel: okLabel?.toUpperCase(), + cancelLabel: cancelLabel?.toUpperCase(), + ) + ], + ), + ); + } +} + +class StyledDialogRoute extends PopupRoute { + StyledDialogRoute({ + @required RoutePageBuilder pageBuilder, + bool barrierDismissible = true, + String barrierLabel, + Color barrierColor = const Color(0x80000000), + Duration transitionDuration = const Duration(milliseconds: 200), + RouteTransitionsBuilder transitionBuilder, + RouteSettings settings, + }) : assert(barrierDismissible != null), + _pageBuilder = pageBuilder, + _barrierDismissible = barrierDismissible, + _barrierLabel = barrierLabel, + _barrierColor = barrierColor, + _transitionDuration = transitionDuration, + _transitionBuilder = transitionBuilder, + super(settings: settings); + + final RoutePageBuilder _pageBuilder; + + @override + bool get barrierDismissible => _barrierDismissible; + final bool _barrierDismissible; + + @override + String get barrierLabel => _barrierLabel; + final String _barrierLabel; + + @override + Color get barrierColor => _barrierColor; + final Color _barrierColor; + + @override + Duration get transitionDuration => _transitionDuration; + final Duration _transitionDuration; + + final RouteTransitionsBuilder _transitionBuilder; + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + return Semantics( + child: _pageBuilder(context, animation, secondaryAnimation), + scopesRoute: true, + explicitChildNodes: true, + ); + } + + @override + Widget buildTransitions( + BuildContext context, Animation animation, Animation secondaryAnimation, Widget child) { + if (_transitionBuilder == null) { + return FadeTransition(opacity: CurvedAnimation(parent: animation, curve: Curves.linear), child: child); + } // Some default transition + return _transitionBuilder(context, animation, secondaryAnimation, child); + } +} diff --git a/flokk_src/lib/styled_components/styled_form_label_input.dart b/flokk_src/lib/styled_components/styled_form_label_input.dart new file mode 100644 index 0000000..2199bb9 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_form_label_input.dart @@ -0,0 +1,174 @@ +import 'dart:math'; + +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/scrolling/styled_horizontal_scroll_view.dart'; +import 'package:flokk/styled_components/styled_group_label.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class StyledFormLabelInput extends StatefulWidget { + //TODO SB@CE - Is this necessary, can't we just pass null and let the default inside StyledSearchTextInput handle it? + static EdgeInsets kDefaultTextInputPadding = EdgeInsets.only(bottom: Insets.sm, top: 4); + + final String hintText; + final bool autoFocus; + final EdgeInsets contentPadding; + final List labels; + final void Function(String) onAddLabel; + final void Function(String) onRemoveLabel; + final void Function(String) onChanged; + final void Function(String) onFieldSubmitted; + final VoidCallback onEditingCancel; + final void Function(bool) onFocusChanged; + + const StyledFormLabelInput({ + this.hintText, + this.autoFocus, + this.contentPadding, + this.labels, + this.onAddLabel, + this.onRemoveLabel, + this.onChanged, + this.onFieldSubmitted, + this.onEditingCancel, + this.onFocusChanged, + Key key, + }) : super(key: key); + + @override + _StyledFormLabelInputState createState() => _StyledFormLabelInputState(); +} + +class _StyledFormLabelInputState extends State { + final GlobalKey _textKey = GlobalKey(); + FocusNode _textFocusNode; + bool _focused = false; + + @override + void initState() { + RawKeyboard.instance.addListener(_handleRawKeyPressed); + super.initState(); + } + + @override + void dispose() { + RawKeyboard.instance.removeListener(_handleRawKeyPressed); + super.dispose(); + } + + @override + void didUpdateWidget(StyledFormLabelInput oldWidget) { + // Detect when a new label was added from the parent and run the same logic as when we add a label from inside this widget + if (widget.labels.length > oldWidget.labels.length) { + _textKey?.currentState?.text = ""; + // At a later time, make sure this field is focused + Future.microtask(() => _textFocusNode?.requestFocus()); + } + super.didUpdateWidget(oldWidget); + } + + void _handleAddLabel(String label) { + widget.onAddLabel(label); + _textKey?.currentState?.text = ""; + // At a later time, make sure this field is focused + Future.microtask(() => _textFocusNode?.requestFocus()); + } + +//TODO SB@CE - Consider using expression-body for these types of one-liners + void _handleRemoveLabel(String label) { + widget.onRemoveLabel(label); + } + + void _handleFocusCreated(FocusNode focus) { + _textFocusNode = focus; + } + + void _handleFocusChanged(bool value) { + widget.onFocusChanged(value); + setState(() => _focused = value); + } + + void _handleRawKeyPressed(RawKeyEvent evt) { + if (evt is RawKeyDownEvent) { + if (_textFocusNode.hasFocus && evt.logicalKey == LogicalKeyboardKey.backspace) { + if (_textKey != null && _textKey.currentState != null && _textKey.currentState.text.isEmpty) { + final tl = widget.labels; + if (tl.isNotEmpty) { + _handleRemoveLabel(tl.last); + } + } + } + } + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + double formWidth = 200; + double inputWidth = formWidth; + //TODO SB@CE - This could be more readable + final labelWidth = (String label) { + return Insets.m + + StringUtils.measure(label.toUpperCase(), TextStyles.Footnote.letterSpace(0)).width + + Insets.sm + + Insets.sm + + 16 + + Insets.sm + + Insets.sm; + }; + //TODO SB@CE - Not a big fan of ommitting type here, reduces scanability + for (final label in widget.labels) { + //TODO SB@CE - This should be called something like measureLabel, or calculateLabelWidth + inputWidth -= labelWidth(label); + } + + return Container( + margin: EdgeInsets.only(bottom: Insets.m), + child: Stack( + children: [ + StyledHorizontalScrollView( + autoScrollDuration: .200.seconds, + autoScrollCurve: Curves.easeIn, + child: Row( + children: [ + for (var label in widget.labels) ...{ + StyledGroupLabel( + text: label, + onClose: () => _handleRemoveLabel(label), + ).padding(right: Insets.sm), + }, + Container( + constraints: BoxConstraints(maxWidth: max(100, inputWidth)), + child: StyledSearchTextInput( + contentPadding: widget.contentPadding ?? StyledFormLabelInput.kDefaultTextInputPadding, + autoFocus: widget.autoFocus, + hintText: "Add label", + maxLines: 1, + key: _textKey, + style: TextStyles.Body1, + onChanged: widget.onChanged, + onFieldSubmitted: _handleAddLabel, + onEditingCancel: widget.onEditingCancel, + onFocusChanged: _handleFocusChanged, + onFocusCreated: _handleFocusCreated, + ), + ), + ], + ), + ), + //TODO SB@CE - This should respect focus color like other form underlines. + Container( + margin: EdgeInsets.only(top: 38), + height: _focused ? 1.8 : 1.2, + color: _focused ? (theme.isDark ? theme.accent2 : theme.accent1Dark) : theme.greyWeak.withOpacity(.35), + ) + ], + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_group_label.dart b/flokk_src/lib/styled_components/styled_group_label.dart new file mode 100644 index 0000000..8796d47 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_group_label.dart @@ -0,0 +1,77 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class StyledGroupLabel extends StatelessWidget { + final AssetImage icon; + final String text; + final Function(bool) onFocusChanged; + final VoidCallback onClose; + final VoidCallback onPressed; + + StyledGroupLabel({this.icon, this.text, this.onFocusChanged, this.onClose, this.onPressed}) + : assert(icon == null || (icon is AssetImage) || (icon is IconData)); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...{ + StyledImageIcon( + icon, + size: 12, + color: theme.surface, + ).center().constrained(width: 30, height: 30).decorated( + borderRadius: Corners.s5Border, + color: theme.accent1Darker, + ), + }, + Text(text.toUpperCase(), style: TextStyles.Footnote.letterSpace(0).textColor(theme.grey)) + .padding(left: Insets.m), + if (onClose != null) ...{ + ColorShiftIconBtn( + StyledIcons.closeLarge, + minWidth: 0, + minHeight: 0, + // 8 padding on either side + 8 icon size = design dimensions, minWidth doesn't seem to work for this so I'm using padding instead + padding: EdgeInsets.all(8), + size: 8, + color: theme.grey, + bgColor: ColorUtils.blend(theme.surface, theme.bg2, .35), + onFocusChanged: onFocusChanged, + onPressed: onClose, + ), + } else ... { + HSpace(Insets.m), + }, + ], + ); + return onPressed != null + ? BaseStyledBtn( + minWidth: 1, + minHeight: 1, + bgColor: ColorUtils.blend(theme.surface, theme.bg2, .35), + onPressed: onPressed, + onFocusChanged: onFocusChanged, + borderRadius: Corners.s5, + contentPadding: EdgeInsets.all(Insets.sm), + child: content, + ) + : Container( + height: 30, + decoration: BoxDecoration(borderRadius: Corners.s5Border, color: theme.bg2.withOpacity(.35)), + child: content, + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_icons.dart b/flokk_src/lib/styled_components/styled_icons.dart new file mode 100644 index 0000000..94e67d9 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_icons.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +class StyledIcons { + static AssetImage get add => AssetImage("assets/icons/icon-add.png"); + + static AssetImage get address => AssetImage("assets/icons/icon-address.png"); + + static AssetImage get birthday => AssetImage("assets/icons/icon-birthday.png"); + + static AssetImage get calendar => AssetImage("assets/icons/icon-calendar.png"); + + static AssetImage get checkboxPartial => AssetImage("assets/icons/icon-checkbox-partial.png"); + + static AssetImage get checkboxSelected => AssetImage("assets/icons/icon-checkbox-selected.png"); + + static AssetImage get closeLarge => AssetImage("assets/icons/icon-close-large.png"); + + static AssetImage get copy => AssetImage("assets/icons/icon-copy.png"); + + static AssetImage get darkMode => AssetImage("assets/icons/icon-darkmode.png"); + + static AssetImage get dashboard => AssetImage("assets/icons/icon-dashboard.png"); + + static AssetImage get dropdownClose => AssetImage("assets/icons/icon-dropdown-close.png"); + + static AssetImage get dropdownOpen => AssetImage("assets/icons/icon-dropdown-open.png"); + + static AssetImage get edit => AssetImage("assets/icons/icon-edit.png"); + + static AssetImage get formAdd => AssetImage("assets/icons/icon-form-add.png"); + + static AssetImage get formAddLabel => AssetImage("assets/icons/icon-form-add-label.png"); + + static AssetImage get formDelete => AssetImage("assets/icons/icon-form-delete.png"); + + static AssetImage get githubActive => AssetImage("assets/icons/icon-github-active.png"); + + static AssetImage get githubEmpty => AssetImage("assets/icons/icon-github-empty.png"); + + static AssetImage get label => AssetImage("assets/icons/icon-label.png"); + + static AssetImage get lightMode => AssetImage("assets/icons/icon-lightmode.png"); + + static AssetImage get link => AssetImage("assets/icons/icon-link.png"); + + static AssetImage get linkout => AssetImage("assets/icons/icon-linkout.png"); + + static AssetImage get mail => AssetImage("assets/icons/icon-mail.png"); + + static AssetImage get next => AssetImage("assets/icons/icon-next.png"); + + static AssetImage get note => AssetImage("assets/icons/icon-note.png"); + + static AssetImage get phone => AssetImage("assets/icons/icon-phone.png"); + + static AssetImage get previous => AssetImage("assets/icons/icon-previous.png"); + + static AssetImage get refresh => AssetImage("assets/icons/icon-refresh.png"); + + static AssetImage get relationship => AssetImage("assets/icons/icon-relationship.png"); + + static AssetImage get save => AssetImage("assets/icons/icon-save.png"); + + static AssetImage get search => AssetImage("assets/icons/icon-search.png"); + + static AssetImage get setting => AssetImage("assets/icons/icon-setting.png"); + + static AssetImage get signOut => AssetImage("assets/icons/icon-signout.png"); + + static AssetImage get socialFork => AssetImage("assets/icons/icon-social-fork.png"); + + static AssetImage get socialLike => AssetImage("assets/icons/icon-social-like.png"); + + static AssetImage get socialRetweet => AssetImage("assets/icons/icon-social-retweet.png"); + + static AssetImage get socialStar => AssetImage("assets/icons/icon-social-star.png"); + + static AssetImage get starEmpty => AssetImage("assets/icons/icon-star-empty.png"); + + static AssetImage get starFilled => AssetImage("assets/icons/icon-star-filled.png"); + + static AssetImage get trash => AssetImage("assets/icons/icon-trash.png"); + + static AssetImage get twitterActive => AssetImage("assets/icons/icon-twitter-active.png"); + + static AssetImage get twitterEmpty => AssetImage("assets/icons/icon-twitter-empty.png"); + + static AssetImage get user => AssetImage("assets/icons/icon-user.png"); + + static AssetImage get work => AssetImage("assets/icons/icon-work.png"); +} diff --git a/flokk_src/lib/styled_components/styled_image_icon.dart b/flokk_src/lib/styled_components/styled_image_icon.dart new file mode 100644 index 0000000..a3d336d --- /dev/null +++ b/flokk_src/lib/styled_components/styled_image_icon.dart @@ -0,0 +1,21 @@ +import 'package:flokk/styles.dart'; +import 'package:flutter/material.dart'; + +//TODO SB: +// * Wherever possible allow it to fallback to the default size. +// * If you find yourself adding similar hardcoded values, feel free to add Sizes.iconSm or Sizes.iconLg +// The idea is to remove as many hard-coded icon sizes as possible from the app and localize them. +// If a size is truly one-off, that's fine to stay hardcoded. + +class StyledImageIcon extends StatelessWidget { + final AssetImage image; + final Color color; + final double size; + + const StyledImageIcon(this.image, {Key key, this.color, this.size}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ImageIcon(image, size: size ?? Sizes.iconMed, color: color ?? Colors.white); + } +} diff --git a/flokk_src/lib/styled_components/styled_label_pill.dart b/flokk_src/lib/styled_components/styled_label_pill.dart new file mode 100644 index 0000000..05e01d3 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_label_pill.dart @@ -0,0 +1,66 @@ +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'buttons/base_styled_button.dart'; + +class StyledLabelPill extends StatelessWidget { + final String text; + final TextStyle textStyle; + final Color color; + final double borderRadius; + final VoidCallback onPressed; + + const StyledLabelPill(this.text, {Key key, this.textStyle, this.color, this.borderRadius, this.onPressed}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + String t = (text.length > 30) ? text.substring(0, 30) : text; + return BaseStyledBtn( + contentPadding: EdgeInsets.symmetric(horizontal: Insets.m, vertical: Insets.sm), + onPressed: onPressed, + bgColor: color ?? ColorUtils.blend(theme.surface, theme.bg2, .35), + hoverColor: color ?? ColorUtils.blend(theme.surface, theme.bg2, .35), + borderRadius: borderRadius, + child: IntrinsicWidth( + child: Container( + alignment: Alignment.center, + child: OneLineText(t ?? "", style: textStyle ?? TextStyles.Btn), + ), + ), + ); + } +} + +class ContactLabelPill extends StatelessWidget { + final String text; + final Color color; + final VoidCallback onPressed; + + const ContactLabelPill(this.text, {Key key, this.color, this.onPressed}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + String t = (text.length > 30) ? text.substring(0, 30) : text; + return IntrinsicWidth( + child: GestureDetector( + onTap: onPressed, + child: Container( + alignment: Alignment.center, + padding: EdgeInsets.symmetric(horizontal: Insets.m, vertical: Insets.sm), + decoration: BoxDecoration( + color: color ?? theme.bg2.withOpacity(.35), + borderRadius: Corners.s5Border, + ), + child: OneLineText(t ?? "", style: TextStyles.Footnote), + ), + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_progress_spinner.dart b/flokk_src/lib/styled_components/styled_progress_spinner.dart new file mode 100644 index 0000000..272933d --- /dev/null +++ b/flokk_src/lib/styled_components/styled_progress_spinner.dart @@ -0,0 +1,22 @@ +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class StyledProgressSpinner extends StatelessWidget { + final Color color; + + const StyledProgressSpinner({Key key, this.color}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(theme.accent1Darker), backgroundColor: color ?? Colors.white), + ), + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_tab_bar.dart b/flokk_src/lib/styled_components/styled_tab_bar.dart new file mode 100644 index 0000000..4bf98a2 --- /dev/null +++ b/flokk_src/lib/styled_components/styled_tab_bar.dart @@ -0,0 +1,69 @@ +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class StyledTabBar extends StatelessWidget { + final Function(int) onTabPressed; + final double width; + final List sections; + final int index; + static const List defaults = ["test", "foo", "bar"]; + + const StyledTabBar({Key key, this.width, this.sections = defaults, this.index, this.onTabPressed}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + + /// Create List of expanding sections's to fill the tab bar + List clickableLabels = sections.map((e) => _ClickableLabel(e, theme)).toList(); + + /// Calculate target alignment for animated bar + double targetAlignX = -1 + (index * 1 / (sections.length - 1)) * 2; + return RepaintBoundary( + child: Stack( + children: [ + /// Outline + _RoundedBox(border: theme.greyWeak), + + /// Animated bar + _RoundedBox(fill: theme.accent1) + .fractionallySizedBox(widthFactor: 1 / sections.length) + .alignment(Alignment(targetAlignX, 0), animate: true) + .animate(Durations.fast, Curves.easeOut), + + /// Clickable Text labels + Row(children: clickableLabels) + ], + ).height(30), + ); + } + + Widget _RoundedBox({double width, Color border, Color fill}) { + return Container( + width: width ?? null, + decoration: BoxDecoration( + color: fill, + borderRadius: Corners.s5Border, + border: Border.all(color: border?.withOpacity(.35) ?? Colors.transparent)), + ); + } + + Widget _ClickableLabel(String e, AppTheme theme, [double fontScale = 1]) { + bool isSelected = sections.indexOf(e) == index; + Color selected = theme.isDark? theme.bg1 : theme.surface; + Color notSelected = theme.isDark? theme.greyStrong : theme.grey; + + return AnimatedDefaultTextStyle( + duration: Durations.fast, + style: TextStyles.Footnote.textColor(isSelected ? selected : notSelected).scale(fontScale), + child: OneLineText(e.toUpperCase()) + .center() + .clickable(()=>onTabPressed?.call(sections.indexOf(e)), opaque: true) + .expanded(), + ); + } +} diff --git a/flokk_src/lib/styled_components/styled_text_input.dart b/flokk_src/lib/styled_components/styled_text_input.dart new file mode 100644 index 0000000..a57b99b --- /dev/null +++ b/flokk_src/lib/styled_components/styled_text_input.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class StyledFormTextInput extends StatelessWidget { + static EdgeInsets kDefaultTextInputPadding = EdgeInsets.only(bottom: Insets.sm, top: 4); + + final String label; + final bool autoFocus; + final String initialValue; + final String hintText; + final EdgeInsets contentPadding; + final TextStyle textStyle; + final int maxLines; + final TextEditingController controller; + final TextCapitalization capitalization; + final Function(String) onChanged; + final Function() onEditingComplete; + final Function(bool) onFocusChanged; + final Function(FocusNode) onFocusCreated; + + const StyledFormTextInput( + {Key key, + this.label, + this.autoFocus, + this.initialValue, + this.onChanged, + this.onEditingComplete, + this.hintText, + this.onFocusChanged, + this.onFocusCreated, + this.controller, + this.contentPadding, + this.capitalization, + this.textStyle, + this.maxLines}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return StyledSearchTextInput( + capitalization: capitalization, + label: label, + autoFocus: autoFocus, + initialValue: initialValue, + onChanged: onChanged, + onFocusCreated: onFocusCreated, + style: textStyle ?? TextStyles.Body1, + onEditingComplete: onEditingComplete, + onFocusChanged: onFocusChanged, + controller: controller, + maxLines: maxLines, + inputDecoration: InputDecoration( + isDense: true, + contentPadding: contentPadding ?? kDefaultTextInputPadding, + border: ThinUnderlineBorder(borderSide: BorderSide(width: 5, color: Colors.red)), + //focusedBorder: UnderlineInputBorder(borderSide: BorderSide(width: .5, color: Colors.red)), + hintText: hintText, + ), + ); + } +} + +class StyledSearchTextInput extends StatefulWidget { + final String label; + final TextStyle style; + final EdgeInsets contentPadding; + final bool autoFocus; + final bool obscureText; + final IconData icon; + final String initialValue; + final int maxLines; + final TextEditingController controller; + final TextCapitalization capitalization; + final TextInputType type; + final bool enabled; + final bool autoValidate; + final bool enableSuggestions; + final bool autoCorrect; + final String errorText; + final String hintText; + final Widget prefixIcon; + final Widget suffixIcon; + final InputDecoration inputDecoration; + + final Function(String) onChanged; + final Function() onEditingComplete; + final Function() onEditingCancel; + final Function(bool) onFocusChanged; + final Function(FocusNode) onFocusCreated; + final Function(String) onFieldSubmitted; + final Function(String) onSaved; + final VoidCallback onTap; + + const StyledSearchTextInput({ + Key key, + this.label, + this.autoFocus = false, + this.obscureText = false, + this.type = TextInputType.text, + this.icon, + this.initialValue = "", + this.controller, + this.enabled, + this.autoValidate = false, + this.enableSuggestions = true, + this.autoCorrect = true, + this.errorText, + this.style, + this.contentPadding, + this.prefixIcon, + this.suffixIcon, + this.inputDecoration, + this.onChanged, + this.onEditingComplete, + this.onEditingCancel, + this.onFocusChanged, + this.onFocusCreated, + this.onFieldSubmitted, + this.onSaved, + this.onTap, + this.hintText, + this.capitalization, + this.maxLines, + }) : super(key: key); + + @override + StyledSearchTextInputState createState() => StyledSearchTextInputState(); +} + +class StyledSearchTextInputState extends State { + TextEditingController _controller; + FocusNode _focusNode; + + @override + void initState() { + _controller = widget.controller ?? TextEditingController(text: widget.initialValue); + _focusNode = FocusNode( + debugLabel: widget.label ?? "", + onKey: (FocusNode node, RawKeyEvent evt) { + if (evt is RawKeyDownEvent) { + if (evt.logicalKey == LogicalKeyboardKey.escape) { + widget.onEditingCancel?.call(); + return true; + } + } + return false; + }, + canRequestFocus: true, + ); + // Listen for focus out events + _focusNode.addListener(() => widget.onFocusChanged?.call(_focusNode.hasFocus)); + widget.onFocusCreated?.call(_focusNode); + if(widget.autoFocus ?? false){ + scheduleMicrotask(()=>_focusNode.requestFocus()); + } + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void clear() => _controller.clear(); + + String get text => _controller.text; + + set text(String value) => _controller.text = value; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return Container( + padding: EdgeInsets.symmetric(vertical: Insets.sm), + child: TextFormField( + onChanged: widget.onChanged, + onEditingComplete: widget.onEditingComplete, + onFieldSubmitted: widget.onFieldSubmitted, + onSaved: widget.onSaved, + onTap: widget.onTap, + autofocus: widget.autoFocus ?? false, + focusNode: _focusNode, + keyboardType: widget.type, + obscureText: widget.obscureText, + autocorrect: widget.autoCorrect, + autovalidate: widget.autoValidate, + enableSuggestions: widget.enableSuggestions, + style: widget.style ?? TextStyles.Body1, + cursorColor: theme.accent1, + controller: _controller, + showCursor: true, + enabled: widget.enabled, + maxLines: widget.maxLines, + textCapitalization: widget.capitalization ?? TextCapitalization.none, + decoration: widget.inputDecoration ?? + InputDecoration( + prefixIcon: widget.prefixIcon ?? null, + suffixIcon: widget.suffixIcon ?? null, + contentPadding: widget.contentPadding ?? EdgeInsets.all(Insets.m), + border: OutlineInputBorder(borderSide: BorderSide.none), + isDense: true, + icon: widget.icon == null ? null : Icon(widget.icon), + errorText: widget.errorText, + errorMaxLines: 2, + hintText: widget.hintText, + hintStyle: TextStyles.Body1.textColor(theme.grey), + labelText: widget.label), + ), + ); + } +} + +class ThinUnderlineBorder extends InputBorder { + /// Creates an underline border for an [InputDecorator]. + /// + /// The [borderSide] parameter defaults to [BorderSide.none] (it must not be + /// null). Applications typically do not specify a [borderSide] parameter + /// because the input decorator substitutes its own, using [copyWith], based + /// on the current theme and [InputDecorator.isFocused]. + /// + /// The [borderRadius] parameter defaults to a value where the top left + /// and right corners have a circular radius of 4.0. The [borderRadius] + /// parameter must not be null. + const ThinUnderlineBorder({ + BorderSide borderSide = const BorderSide(), + this.borderRadius = const BorderRadius.only( + topLeft: Radius.circular(4.0), + topRight: Radius.circular(4.0), + ), + }) : assert(borderRadius != null), + super(borderSide: borderSide); + + /// The radii of the border's rounded rectangle corners. + /// + /// When this border is used with a filled input decorator, see + /// [InputDecoration.filled], the border radius defines the shape + /// of the background fill as well as the bottom left and right + /// edges of the underline itself. + /// + /// By default the top right and top left corners have a circular radius + /// of 4.0. + final BorderRadius borderRadius; + + @override + bool get isOutline => false; + + @override + UnderlineInputBorder copyWith({BorderSide borderSide, BorderRadius borderRadius}) { + return UnderlineInputBorder( + borderSide: borderSide ?? this.borderSide, + borderRadius: borderRadius ?? this.borderRadius, + ); + } + + @override + EdgeInsetsGeometry get dimensions { + return EdgeInsets.only(bottom: borderSide.width); + } + + @override + UnderlineInputBorder scale(double t) { + return UnderlineInputBorder(borderSide: borderSide.scale(t)); + } + + @override + Path getInnerPath(Rect rect, {TextDirection textDirection}) { + return Path() + ..addRect(Rect.fromLTWH(rect.left, rect.top, rect.width, math.max(0.0, rect.height - borderSide.width))); + } + + @override + Path getOuterPath(Rect rect, {TextDirection textDirection}) { + return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); + } + + @override + ShapeBorder lerpFrom(ShapeBorder a, double t) { + if (a is UnderlineInputBorder) { + return UnderlineInputBorder( + borderSide: BorderSide.lerp(a.borderSide, borderSide, t), + borderRadius: BorderRadius.lerp(a.borderRadius, borderRadius, t), + ); + } + return super.lerpFrom(a, t); + } + + @override + ShapeBorder lerpTo(ShapeBorder b, double t) { + if (b is UnderlineInputBorder) { + return UnderlineInputBorder( + borderSide: BorderSide.lerp(borderSide, b.borderSide, t), + borderRadius: BorderRadius.lerp(borderRadius, b.borderRadius, t), + ); + } + return super.lerpTo(b, t); + } + + /// Draw a horizontal line at the bottom of [rect]. + /// + /// The [borderSide] defines the line's color and weight. The `textDirection` + /// `gap` and `textDirection` parameters are ignored. + @override + void paint( + Canvas canvas, + Rect rect, { + double gapStart, + double gapExtent = 0.0, + double gapPercentage = 0.0, + TextDirection textDirection, + }) { + print("Width: ${borderSide.width}"); + if (borderRadius.bottomLeft != Radius.zero || borderRadius.bottomRight != Radius.zero) + canvas.clipPath(getOuterPath(rect, textDirection: textDirection)); + canvas.drawLine(rect.bottomLeft, rect.bottomRight, borderSide.toPaint()); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + return other is InputBorder && other.borderSide == borderSide; + } + + @override + int get hashCode => borderSide.hashCode; +} diff --git a/flokk_src/lib/styled_components/styled_user_avatar.dart b/flokk_src/lib/styled_components/styled_user_avatar.dart new file mode 100644 index 0000000..59c068e --- /dev/null +++ b/flokk_src/lib/styled_components/styled_user_avatar.dart @@ -0,0 +1,109 @@ +import 'dart:math'; + +import 'package:flokk/data/contact_data.dart'; +import 'package:flutter/material.dart'; + +class StyledUserAvatar extends StatefulWidget { + final ContactData contact; + final double size; + + const StyledUserAvatar({Key key, this.contact, this.size}) : super(key: key); + + @override + _StyledUserAvatarState createState() => _StyledUserAvatarState(); +} + +class _StyledUserAvatarState extends State { + int _seed; + + @override + void initState() { + _seed = widget.contact?.id?.hashCode ?? 0; + super.initState(); + } + + @override + void didUpdateWidget(StyledUserAvatar oldWidget) { + if (oldWidget.contact.profilePicBytes != widget.contact.profilePicBytes) { + setState(() {}); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + + Widget child; + if (widget.contact.profilePicBytes != null) { + child = Image.memory(widget.contact.profilePicBytes, fit: BoxFit.cover); + } else if (widget.contact.profilePic != null && !widget.contact.isDefaultPic) { + child = Image.network(widget.contact.profilePic, fit: BoxFit.cover); + } else { + child = AnimalAvatar(seed: _seed); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(99), + child: Container( + width: widget.size ?? 50, + height: widget.size ?? 50, + child: child, + ), + ); + } +} + +class AnimalAvatar extends StatelessWidget { + final int seed; + + final List backgrounds = [ + Color(0xFF44D3B8), + Color(0xFFACC66B), + Color(0xFF915599), + Color(0xFF85CADB), + Color(0xFF37598C), + Color(0xFF5A5587), + Color(0xFFD4B99F), + Color(0xFFEDABA9), + Color(0xFFE09BD6), + Color(0xFFF4A647), + ]; + + final List foregrounds = [ + "bird-hummingbird", + "bird-parrot", + "bird-pelican", + "bird-swan", + "bird-woodpecker", + "bird-flamingo", + "bird-owl", + "bird-peacock", + "bird-penguin", + "bird-toucan" + ]; + + AnimalAvatar({Key key, this.seed}) : super(key: key); + + @override + Widget build(BuildContext context) { + Random r = Random(seed); + return Stack( + children: [ + Container( + color: backgrounds[r.nextInt(backgrounds.length)], + ), + Image.asset("assets/images/birds/${foregrounds[r.nextInt(foregrounds.length)]}.png"), + ], + ); + } +} + +//Widget _buildAvatar(double size) { +// Widget content = (widget.contact.profilePic == null +// ? Container() +// : ClipRRect( +// borderRadius: BorderRadius.circular(999), +// child: Image.network(widget.contact.profilePic, fit: BoxFit.cover), +// )); +// return content.constrained(width: size, height: size); +//} diff --git a/flokk_src/lib/styled_components/textinput_icon_row.dart b/flokk_src/lib/styled_components/textinput_icon_row.dart new file mode 100644 index 0000000..39a2e58 --- /dev/null +++ b/flokk_src/lib/styled_components/textinput_icon_row.dart @@ -0,0 +1,37 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flutter/material.dart'; + +class TextInputIconRow extends StatelessWidget { + final IconData icon; + final bool autoFocus; + final String initialValue; + final String hintText; + final Function(String) onChanged; + final Function() onEditingComplete; + final Function(bool) onFocusChanged; + + const TextInputIconRow(this.icon, this.hintText, + {Key key, this.autoFocus, this.initialValue, this.onChanged, this.onEditingComplete, this.onFocusChanged}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 24, color: Colors.grey), + HSpace(Insets.l), + StyledFormTextInput( + autoFocus: autoFocus, + initialValue: initialValue, + onChanged: onChanged, + onFocusChanged: onFocusChanged, + onEditingComplete: onEditingComplete, + hintText: hintText, + ).flexible() + ], + ); + } +} diff --git a/flokk_src/lib/styles.dart b/flokk_src/lib/styles.dart new file mode 100644 index 0000000..b3ad578 --- /dev/null +++ b/flokk_src/lib/styles.dart @@ -0,0 +1,189 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:textstyle_extensions/textstyle_extensions.dart'; + +export 'package:textstyle_extensions/textstyle_extensions.dart'; + +class Durations { + static Duration get fastest => .15.seconds; + + static Duration get fast => .25.seconds; + + static Duration get medium => .35.seconds; + + static Duration get slow => .7.seconds; +} + +class Fonts { + static const String lato = "Lato"; + + static const String quicksand = "Quicksand"; + + static const String emoji = "OpenSansEmoji"; +} + +class PageBreaks { + static double get LargePhone => 550; + + static double get TabletPortrait => 768; + + static double get TabletLandscape => 1024; + + static double get Desktop => 1440; +} + +class Insets { + static double gutterScale = 1; + + static double scale = 1; + + /// Dynamic insets, may get scaled with the device size + static double get mGutter => m * gutterScale; + + static double get lGutter => l * gutterScale; + + static double get xs => 2 * scale; + + static double get sm => 6 * scale; + + static double get m => 12 * scale; + + static double get l => 24 * scale; + + static double get xl => 36 * scale; +} + +class FontSizes { + static double get scale => 1; + + static double get s11 => 11 * scale; + + static double get s12 => 12 * scale; + + static double get s14 => 14 * scale; + + static double get s16 => 16 * scale; + + static double get s18 => 18 * scale; +} + +class Sizes { + static double hitScale = 1; + + static double get hit => 40 * hitScale; + + static double get iconMed => 20; + + static double get sideBarSm => 150 * hitScale; + + static double get sideBarMed => 200 * hitScale; + + static double get sideBarLg => 290 * hitScale; +} + +class TextStyles { + static const TextStyle lato = TextStyle( + fontFamily: Fonts.lato, + fontWeight: FontWeight.w400, + letterSpacing: 0, + height: 1, + fontFamilyFallback: [ + Fonts.emoji, + ], + ); + + static const TextStyle quicksand = TextStyle( + fontFamily: Fonts.quicksand, + fontWeight: FontWeight.w400, + fontFamilyFallback: [ + Fonts.emoji, + ], + ); + + static TextStyle get T1 => quicksand.bold.size(FontSizes.s14).letterSpace(.7); + + static TextStyle get T2 => lato.bold.size(FontSizes.s12).letterSpace(.4); + + static TextStyle get H1 => lato.bold.size(FontSizes.s14); + + static TextStyle get H2 => lato.bold.size(FontSizes.s12); + + static TextStyle get Body1 => lato.size(FontSizes.s14); + + static TextStyle get Body2 => lato.size(FontSizes.s12); + + static TextStyle get Body3 => lato.size(FontSizes.s11); + + static TextStyle get Callout => quicksand.size(FontSizes.s14).letterSpace(1.75); + + static TextStyle get CalloutFocus => Callout.bold; + + static TextStyle get Btn => quicksand.bold.size(FontSizes.s14).letterSpace(1.75); + + static TextStyle get BtnSelected => quicksand.size(FontSizes.s14).letterSpace(1.75); + + static TextStyle get Footnote => quicksand.bold.size(FontSizes.s11); + + static TextStyle get Caption => lato.size(FontSizes.s11).letterSpace(.3); +} + +class Shadows { + static bool enabled = true; + + static double get mRadius => 8; + + static List m(Color color, [ double opacity = 0]) { + return enabled + ? [ + BoxShadow( + color: color.withOpacity(opacity ?? .03), + blurRadius: mRadius, + spreadRadius: mRadius / 2, + offset: Offset(1, 0), + ), + BoxShadow( + color: color.withOpacity(opacity ?? .04), + blurRadius: mRadius / 2, + spreadRadius: mRadius / 4, + offset: Offset(1, 0), + ) + ] + : null; + } +} + +class Corners { + static double get btn => s5; + + static double get dialog => 12; + + /// Xs + static double get s3 => 3; + + static BorderRadius get s3Border => BorderRadius.all(s3Radius); + + static Radius get s3Radius => Radius.circular(s3); + + /// Small + static double get s5 => 5; + + static BorderRadius get s5Border => BorderRadius.all(s5Radius); + + static Radius get s5Radius => Radius.circular(s5); + + /// Medium + static double get s8 => 8; + + static BorderRadius get s8Border => BorderRadius.all(s8Radius); + + static Radius get s8Radius => Radius.circular(s8); + + /// Large + static double get s10 => 10; + + static BorderRadius get s10Border => BorderRadius.all(s10Radius); + + static Radius get s10Radius => Radius.circular(s10); +} + diff --git a/flokk_src/lib/tests/button_tests.dart b/flokk_src/lib/tests/button_tests.dart new file mode 100644 index 0000000..a773017 --- /dev/null +++ b/flokk_src/lib/tests/button_tests.dart @@ -0,0 +1,67 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/buttons/primary_btn.dart'; +import 'package:flokk/styled_components/buttons/secondary_btn.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ButtonTests extends StatelessWidget { + @override + Widget build(BuildContext context) { + void p() => print("CLick1"); + AppTheme theme = context.watch(); + return Scaffold( + backgroundColor: Colors.white, + body: Center( + child: SeparatedRow( + mainAxisAlignment: MainAxisAlignment.center, + separatorBuilder: () => HSpace(Insets.l), + children: [ + SeparatedColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => VSpace(Insets.m), + children: [ + ColorShiftIconBtn(StyledIcons.add, color: theme.accent1, onPressed: p), + ]), + SeparatedColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => VSpace(Insets.m), + children: [ + SecondaryIconBtn(StyledIcons.add, onPressed: p), + SecondaryBtn(child: FlutterLogo(), onPressed: p), + SecondaryTextBtn("STAY ON THIS PAGE", onPressed: p), + TransparentBtn( + child: Text("CLICK ME!", style: TextStyles.Footnote.textColor(theme.accent1)), onPressed: p), + TransparentBtn( + bigMode: true, + child: Text( + "CLICK ME!", + style: TextStyles.Caption.textColor(theme.accent1), + ), + onPressed: p), + ], + ), + SeparatedColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + separatorBuilder: () => VSpace(Insets.m), + children: [ + PrimaryTextBtn("SAVE", onPressed: p), + PrimaryTextBtn("SAVE", bigMode: true, onPressed: p), + PrimaryBtn(onPressed: p, child: Text("SAVE FOR WEB", style: TextStyles.Footnote)), + PrimaryBtn(onPressed: p, child: Text("SAVE FOR WEB", style: TextStyles.Footnote), bigMode: true), + ], + ) + ], + ), + ), + ); + } +} diff --git a/flokk_src/lib/tests/command_testing_spike.dart b/flokk_src/lib/tests/command_testing_spike.dart new file mode 100644 index 0000000..95450bb --- /dev/null +++ b/flokk_src/lib/tests/command_testing_spike.dart @@ -0,0 +1,118 @@ +import 'dart:math'; + +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/commands/groups/add_label_to_contact_command.dart'; +import 'package:flokk/commands/groups/create_label_command.dart'; +import 'package:flokk/commands/groups/delete_label_command.dart'; +import 'package:flokk/commands/groups/refresh_contact_groups_command.dart'; +import 'package:flokk/commands/groups/remove_label_from_contact_command.dart'; +import 'package:flokk/commands/groups/rename_label_command.dart'; +import 'package:flokk/commands/groups/update_contact_labels_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class CommandTestingSpike extends StatelessWidget { + @override + Widget build(BuildContext context) { + final contactModel = Provider.of(context, listen: false); + ContactData contact = contactModel.allContacts.first; + GroupData group; + + return Container( + child: Column( + children: [ + Text("Test contact to apply labels to: ${contact.nameFull}"), + RaisedButton( + child: Text("refresh groups"), + onPressed: () async { + List groups = await RefreshContactGroupsCommand(context).execute(); + print(groups.map((x) => x.name).toList().join(",")); + }, + ), + RaisedButton( + child: Text("refresh contacts"), + onPressed: () async { + await RefreshContactsCommand(context).execute(); + }, + ), + RaisedButton( + child: Text("create label"), + onPressed: () async { + group = await CreateLabelCommand(context).execute("MyNewLabel"); + print(group); + }, + ), + RaisedButton( + child: Text("edit label"), + onPressed: () async { + group.name = "Renamed Label"; + group = await RenameLabelCommand(context).execute(group); + print(group); + }, + ), + RaisedButton( + child: Text("add multiple labels to single contact"), + onPressed: () async { + List userLabels = + contactModel.allGroups.where((x) => x.groupType == GroupType.UserContactGroup)?.toList() ?? []; + int length = min(userLabels.length, 3); + List firstThreeLabels = userLabels.sublist(0, length); + contact.groupList = firstThreeLabels; + firstThreeLabels.forEach((element) { + print("LABEL: ${element.name}"); + }); + UpdateContactLabelsCommand(context).execute(contact); + }, + ), + RaisedButton( + child: Text("add single label to multiple contacts"), + onPressed: () async { + GroupData firstLabel = + contactModel.allGroups?.where((x) => x.groupType == GroupType.UserContactGroup)?.first ?? null; + if (firstLabel != null) { + List faves = contactModel.allContacts.where((x) => x.isStarred == true)?.toList(); + int length = min(faves.length, 3); + List firstThreeContacts = faves.sublist(0, length); + AddLabelToContactCommand(context).execute(firstThreeContacts, existingGroup: firstLabel); + print("Add ${firstLabel.name} to ${firstThreeContacts.map((x) => x.nameFull).toList().join(', ')}"); + } + }, + ), + RaisedButton( + child: Text("add new label to contact"), + onPressed: () async { + List updatedContact = + await AddLabelToContactCommand(context).execute([contact], newLabel: "Foo"); + print(updatedContact.first); + }, + ), + RaisedButton( + child: Text("add existing label to contact"), + onPressed: () async { + List updatedContact = + await AddLabelToContactCommand(context).execute([contact], existingGroup: group); + print(updatedContact.first); + }, + ), + RaisedButton( + child: Text("remove label from contact"), + onPressed: () async { + ContactData updatedContact = await RemoveLabelFromContactCommand(context).execute(contact, group); + print(updatedContact); + }, + ), + RaisedButton( + child: Text("delete label"), + onPressed: () async { + bool success = await DeleteLabelCommand(context).execute(group); + print(success); + }, + ), + ], + ), + ); + } +} diff --git a/flokk_src/lib/tests/native_smoke_test.dart b/flokk_src/lib/tests/native_smoke_test.dart new file mode 100644 index 0000000..674b884 --- /dev/null +++ b/flokk_src/lib/tests/native_smoke_test.dart @@ -0,0 +1,76 @@ +import 'package:flokk/_internal/url_launcher/url_launcher.dart'; +import 'package:flokk/_internal/utils/path.dart'; +import 'package:flokk/_internal/utils/picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:window_size/window_size.dart'; + +class NativeSmokeTest extends StatefulWidget { + @override + _NativeSmokeTestState createState() => _NativeSmokeTestState(); +} + +class _NativeSmokeTestState extends State { + String _dataPath; + String _imagePath; + + @override + void initState() { + _fetchAsyncContent(); + super.initState(); + } + + void _fetchAsyncContent() async { + final dataPath = await PathUtil.dataPath; + + setState(() => _dataPath = dataPath); + } + + void _handlePickImage() async { + final imagePath = await pickImage(confirmText: "Choose Image"); + + setState(() => _imagePath = imagePath); + } + + void _handleSetWindowRect() async { + setWindowFrame(Rect.fromLTWH(8, 8, 256, 256)); + } + + void _handleSetWindowMinSize() async { + setWindowMinSize(Size(512, 512)); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: Column( + children: [ + Text("Data path: $_dataPath"), + MaterialButton( + onPressed: () => UrlLauncher.open("https://google.com"), + child: Text("Open url"), + ), + MaterialButton( + onPressed: () => Clipboard.setData(ClipboardData()), + child: Text("Copy \"clipboard test\" to clipboard"), + ), + MaterialButton( + onPressed: _handlePickImage, + child: Text("Open file picker"), + ), + Text("Image path: $_imagePath"), + MaterialButton( + onPressed: _handleSetWindowRect, + child: Text("Set window dimensions"), + ), + MaterialButton( + onPressed: _handleSetWindowMinSize, + child: Text("Set window min size"), + ), + ], + ), + ), + ); + } +} diff --git a/flokk_src/lib/themes.dart b/flokk_src/lib/themes.dart new file mode 100644 index 0000000..8f99938 --- /dev/null +++ b/flokk_src/lib/themes.dart @@ -0,0 +1,126 @@ +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +enum ThemeType { + FlockGreen, + FlockGreen_Dark, +} + +class AppTheme { + static ThemeType defaultTheme = ThemeType.FlockGreen; + + bool isDark; + Color bg1; // + Color surface; // + Color bg2; + Color accent1; + Color accent1Dark; + Color accent1Darker; + Color accent2; + Color accent3; + Color grey; + Color greyStrong; + Color greyWeak; + Color error; + Color focus; + + Color txt; + Color accentTxt; + + /// Default constructor + AppTheme({@required this.isDark}) { + txt = isDark ? Colors.white : Colors.black; + accentTxt = accentTxt ?? isDark ? Colors.black : Colors.white; + } + + /// fromType factory constructor + factory AppTheme.fromType(ThemeType t) { + Color c(String value) => ColorUtils.parseHex(value); + switch (t) { + case ThemeType.FlockGreen: + return AppTheme(isDark: false) + ..bg1 = Color(0xfff1f7f0) + ..bg2 = Color(0xffc1dcbc) + ..surface = Colors.white + ..accent1 = Color(0xff00a086) + ..accent1Dark = Color(0xff00856f) + ..accent1Darker = Color(0xff006b5a) + ..accent2 = Color(0xfff09433) + ..accent3 = Color(0xff5bc91a) + ..greyWeak = Color(0xff909f9c) + ..grey = Color(0xff515d5a) + ..greyStrong = Color(0xff151918) + ..error = Colors.red.shade900 + ..focus = Color(0xFF0ee2b1); + + case ThemeType.FlockGreen_Dark: + return AppTheme(isDark: true) + ..bg1 = Color(0xff121212) + ..bg2 = Color(0xff2c2c2c) + ..surface = Color(0xff252525) + ..accent1 = Color(0xff00a086) + ..accent1Dark = Color(0xff00caa5) + ..accent1Darker = Color(0xff00caa5) + ..accent2 = Color(0xfff19e46) + ..accent3 = Color(0xff5BC91A) + ..greyWeak = Color(0xffa8b3b0) + ..grey = Color(0xffced4d3) + ..greyStrong = Color(0xffffffff) + ..error = Color(0xffe55642) + ..focus = Color(0xff0ee2b1); + /* + case ThemeType.FlockGreen_Dark: + return AppTheme(isDark: true) + ..bg1 = Color(0xff212529) + ..surface = Color(0xff2a2e32) + ..bg2 = Color(0xff272b2f) + ..accent1 = Color(0xff00a086) + ..accent1Dark = Color(0xff00856f) + ..accent1Darker = Color(0xff006b5a) + ..accent2 = Color(0xfff09433) + ..accent3 = Color(0xff5bc91a) + ..greyWeak = Color(0xff151918) + ..grey = Color(0xff6c6c6c) + ..greyStrong = Color(0xff909f9c) + ..error = Colors.red.shade900 + ..focus = Color(0xb30ee2b1); + */ + } + return AppTheme.fromType(defaultTheme); + } + + ThemeData get themeData { + var t = ThemeData.from( + textTheme: (isDark ? ThemeData.dark() : ThemeData.light()).textTheme, + colorScheme: ColorScheme( + brightness: isDark ? Brightness.dark : Brightness.light, + primary: accent1, + primaryVariant: accent1Darker, + secondary: accent2, + secondaryVariant: ColorUtils.shiftHsl(accent2, -.2), + background: bg1, + surface: surface, + onBackground: txt, + onSurface: txt, + onError: txt, + onPrimary: accentTxt, + onSecondary: accentTxt, + error: error ?? Colors.red.shade400), + ); + return t.copyWith( + inputDecorationTheme: InputDecorationTheme( + border: ThinUnderlineBorder(), + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + textSelectionColor: greyWeak, + textSelectionHandleColor: Colors.transparent, + buttonColor: accent1, + cursorColor: accent1, + highlightColor: accent1, + toggleableActiveColor: accent1); + } + + Color shift(Color c, double d) => ColorUtils.shiftHsl(c, d * (isDark? -1 : 1)); +} diff --git a/flokk_src/lib/todo.txt b/flokk_src/lib/todo.txt new file mode 100644 index 0000000..0105f40 --- /dev/null +++ b/flokk_src/lib/todo.txt @@ -0,0 +1,151 @@ +///////////////////////////////////////////////// +// TODO: COLEMAN + * [x] Edit Panel - Finish Picture Upload (ContactEditPanel.handlePhotoPressed) + * [x] Edit Panel - Add Label selector (ContactEditPanelView) + * Will need to use a 'MiniForm'. Tricky to understand, but pretty easy to use + * Lots of examples in /miniforms + * Key thing is that the 'MiniForm' needs to dispatch up a FocusChangedNotification upwards, as Focus changes on it's own children + * [x] Update StyledScrollBar + List to support horizontal scrolling + * [x] Add 'smokeTest' NativeExtension Spike so we can easily test different platforms + * [x] Should let us test everything that gets touched when we upgrade SDK (Display AppData folder, Test all extensions, etc) + * [x] Add All Icons (when ready from design) + * [x] Fix padding on bottom of label header in search dropdown + * [x] Search hscroll should not include icons + * [x] Update search filtering + * [x] Line in label miniform + * [x] Label MiniForm should close suggestions when not focused + * [x] Focus label miniform text field after adding a new label + * [x] Get clipping and animation into the splash screen + WED + * [x] Splash screen is getting clipped at certain resolutions + * make a spike with just the splash screen, and test it thoroughly + * [x] Fix search hotkey kicking you out of fields + * [x] Label Miniform should not close after clicking a label + + THUR + * [x] Use solid background colors for avatars instead of images + * [x] Search should not show results as suggestions + * [ ] Code cleanup + * [ ] Polish and Tune Tab Navigation across the app, with a focus on Dashboard. Ideally can navigate from right to left, across the whole app. + + FRI + * [ ] Dropdown tabbing + * [ ] Talk to Yoon and figure out what we want to do with mouse-over/shadows on the various Dashboard Cards + + +///////////////////////////////////////////////// +// TODO EDDIE + +* Optimize service calls, lets start counting them, making sure we're optimizing + +* Examine startup time, it's getting quite long with the social accts in. + * Get a breakdown of where the time is going, and see what we can do? + +* Make sure nothing can break app startup, throw everything we can at the loading + +* Impose low-level social refresh rates at the command layer + * Twitter - 1m refresh limit + * Git - 5m for events, 72 hrs for repo info + * Global Contacts Refresh - 20s + * Git timeouts should be respected on startup (mainly with regards to repo info) + * The idea with implementing it at a low level, is: + * we can can be kinda sloppy with our command calls from the UI (like double-loading on startup), + * we know they are basically cached calls, won't slow down the app or waste service calls + +///////////////////////////////////////////////// +// TODO SHAWN + +/* Todo: GENERAL + +//* Add search results overlay view (need.searchEngine.hasContent or something...) +* Warning / Cleanup Pass + +THURS +* DarkModeSwitcher +* Finish Sidebar +* Polish + * Mouse Overs for Social Renderers + Tabs + * Styling Bugs in YouTrack +* Refresh Button +* SettingsPanel +* Mobile Layout + +FRI + +* Do a organization/cleanup pass on components and styled_components + * split spikes from _internal + * Remove ImageIcon's + +// TODO: CONTACTS +* Refactor AbstractWidget class into Separate widgets +* Edit - Finish date-picker + +// TODO: SEARCH + +// TODO: Bugs +* Dropdown does not support tab controls + +/// TODO: POLISH +* Contact Panel: Add scrollbar? +* Panel - Switch back to Info panel when cancelling add + +//* Contact List +//* Empty States +//* Social Content Panel + TouchSocialData +//* Create Social Picker Overlay +//* Responsive TabBar on Dash + +/./* Final styling pass w/ Web +// * Maybe remove the overlay if there are no search results +// * Look into having some default suggested items +// * Fix search results landing page heading text +//* Contact Info - Add Edit accelerator on mouseover +//* Contact Info - Add Edit accelerator on icon-click +//* Dashboard: Tab bars get cutoff when panel is open +//* Onboard Views +//* Update Btns to match comps (Large, PrimaryBtn, SecondaryBtn, SquareIcon) + // * Standardize Outlines on RollOver + +//* Style dialogs +//* Finish bulk-edit check/delete +//* See if ListView.cacheExtent can help our lists (did not!) +//* Edit - Add dual column text inputs for address +//* Dropdown overlay needs to scroll with it's owner +//* Edit - Add "Delete This Contact" +//* Info: Left-Hand button should be grey +//* Edit - Add new btn +//* Panel - Throw a dialog if they cancel pending edits, only if something has been changed. +//* Info - Tweak line height for address +//* Edit - Get Add New working +//* Edit - Basic opening/close widget +//* Edit - Add dynamic 'autoFocus' support +//* Edit - Add type dropdowns + +//* Figure out labels implementation +//* Hook up Star buttons +//* Hook up edit button, get initial form structure in place +//* Finish Info Window +//* Add checkbox's +//* Refactor Commands to remove global singletons (what to do for Dialog? Same thing?) +//* Scrollbar support +//* Initial layout scaffolding +//* Model support for groups +//* Json code generation + +// * Get contact updating working +// Add animations page view support +// Filtering and sorting +// Desktop build(s) running +// Basic view architecture & state management + + +* POC App: +//* - Oauth Signup > Code Display +//* - Copy to Clipboard +//* - Main Contacts List +* - Add / Remove / Update +* - Launch URL + */ + +Coleman Notes: + - Backspace deletes labels in search diff --git a/flokk_src/lib/views/contact_edit/contact_edit_panel.dart b/flokk_src/lib/views/contact_edit/contact_edit_panel.dart new file mode 100644 index 0000000..17e0b86 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/contact_edit_panel.dart @@ -0,0 +1,156 @@ +import 'package:flokk/_internal/universal_picker/universal_picker.dart'; +import 'package:flokk/commands/contacts/delete_contact_command.dart'; +import 'package:flokk/commands/contacts/update_contact_command.dart'; +import 'package:flokk/commands/contacts/update_pic_command.dart'; +import 'package:flokk/commands/dialogs/show_discard_warning_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/styled_components/styled_dialogs.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel_view.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactSectionType { + static String name = "name"; + static String label = "label"; + static String email = "email"; + static String phone = "phone"; + static String github = "github"; + static String twitter = "twitter"; + static String address = "address"; + static String birthday = "birthday"; + static String job = "job"; + static String events = "event"; + static String websites = "link"; + static String notes = "notes"; + static String relationship = "relationship"; +} + +class ContactEditForm extends StatefulWidget { + final ContactData contact; + final ContactsModel contactsModel; + final Function(ContactData contact) onEditComplete; + final String initialSection; + + const ContactEditForm({Key key, this.contact, this.contactsModel, this.onEditComplete, this.initialSection}) + : super(key: key); + + @override + ContactEditFormState createState() => ContactEditFormState(); +} + +class ContactEditFormState extends State { + ContactData tmpContact; + bool isLoading = false; + String currentSection; + + // Convenience lookup method for the mini-forms. + // Since they are stateless, and use a lot of internal buildMethods, it's a lot less + // boilerplate if they can just fetch the theme here, + AppTheme get theme => Provider.of(context, listen: false); + + bool get isDirty { + if (tmpContact == null) return false; + // Create a copy of our tmp object, and strip it of empty listItems, + var tc = tmpContact.copy().trimLists(); + return !tc.equals(widget.contact); + } + + @override + void initState() { + tmpContact = widget.contact.copy(); + currentSection = widget.initialSection; + super.initState(); + } + + @override + void didUpdateWidget(ContactEditForm oldWidget) { + if (tmpContact == null || oldWidget.contact != widget.contact) { + tmpContact = widget.contact.copy(); + } + super.didUpdateWidget(oldWidget); + } + + void handleSavePressed() async { + bool success = true; + print("================= SAVE PRESSED ======================"); + + /// Strip contact of any empty list items before saving + ContactData contact = tmpContact.copy().trimLists(); + + // Adding a new contact? + if (contact.isNew) { + //Prevent them from adding an empty contact + if (contact.equals(ContactData())) { + success = false; + await Dialogs.show(OkCancelDialog( + message: "You can not add a completely empty contact. Add some info!", + onOkPressed: () => Navigator.pop(context), + )); + } + //Continue to add new contact + else { + setState(() => isLoading = true); + // Wait for add-new command to complete, since it would be overly complicated to create a tmpUser + contact = await UpdateContactCommand(context).execute(contact, updateSocial: contact.hasAnySocial); + // Upload their image if it's changed + if (tmpContact.hasNewProfilePic) { + UpdatePicCommand(context).execute(contact, tmpContact.profilePicBase64); + } + // If we have a valid contact here, all is good + success = contact != null; + if(mounted){ + setState(() => isLoading = false); + } + } + } else { + // Updating a contact, don't wait, just assume it will work. Data will get updated locally. + UpdateContactCommand(context).execute(contact, updateSocial: !contact.hasSameSocial(widget.contact)); + // Upload their image if it's changed. + if (tmpContact.hasNewProfilePic) { + UpdatePicCommand(context).execute(contact, tmpContact.profilePicBase64); + } + } + if (success) { + widget.onEditComplete?.call(contact); + //Edit is complete, make sure this contact is the currently selected + context?.read()?.selectedContact = contact; + + } + } + + void handleDeletePressed() async => await DeleteContactCommand(context).execute([widget.contact]); + + void handleCancelPressed() async { + bool doCancel = true; + if (isDirty) { + doCancel = await ShowDiscardWarningCommand(context).execute(); + } + if (doCancel ?? false) { + /// If we're cancelling a new contact, return null indicating that it should be discarded + widget.onEditComplete?.call(tmpContact.isNew ? null : widget.contact); + } + } + + void handleSectionChanged(String section) { + setState(() => currentSection = section); + } + + void rebuild() => setState(() {}); + + void handlePhotoPressed() async { + final picker = UniversalPicker(accept: "image/"); + picker.onChange = (String e) { + tmpContact.profilePicBase64 = picker.base64Data; + tmpContact.profilePicBytes = picker.byteData; + tmpContact.hasNewProfilePic = true; + }; + + picker.open(); + } + + @override + Widget build(BuildContext context) => ContactEditFormView(this); +} diff --git a/flokk_src/lib/views/contact_edit/contact_edit_panel_view.dart b/flokk_src/lib/views/contact_edit/contact_edit_panel_view.dart new file mode 100644 index 0000000..5d48de0 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/contact_edit_panel_view.dart @@ -0,0 +1,145 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/_internal/widget_view.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/social_activity_type.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/scrolling/styled_scrollview.dart'; +import 'package:flokk/styled_components/styled_progress_spinner.dart'; +import 'package:flokk/styled_components/styled_user_avatar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/address_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/birthday_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/email_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/events_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/job_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/label_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/name_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/notes_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/phone_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/relationship_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/social_miniforms.dart'; +import 'package:flokk/views/contact_edit/miniforms/website_miniform.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactEditFormView extends WidgetView { + ContactEditFormView(ContactEditFormState state, {Key key}) : super(state, key: key); + + BuildContext get context => state.context; + + ContactData get contact => state.tmpContact; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + if (widget.contact == null || contact == null) return Container(); + return state.isLoading + ? Center(child: StyledProgressSpinner()) + : Column( + children: [ + SizedBox(height: Insets.sm), + + /// Top Buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TransparentTextBtn( + "CANCEL", + bigMode: true, + color: theme.grey, + onPressed: state.handleCancelPressed, + ).translate(offset: Offset(-Insets.sm, 0)), + TransparentTextBtn( + "SAVE", + bigMode: true, + onPressed: state.handleSavePressed, + ).translate(offset: Offset(Insets.sm, 0)), + ], + ).padding(horizontal: Insets.l), + + StyledScrollView( + child: Column( + children: [ + VSpace(3), + /// Profile Pic + StyledUserAvatar(contact: contact, size: 110), + + VSpace(Insets.sm), + + TransparentTextBtn( + "Upload a photo", + bigMode: true, + onPressed: state.handlePhotoPressed, + ), + + VSpace(Insets.l), + + /// Form fields + SeparatedColumn( + separatorBuilder: () => VSpace(Insets.m), + mainAxisSize: MainAxisSize.min, + children: [ + /// Name + ContactNameMiniForm(state), + + /// Labels + ContactLabelMiniForm(state), + + /// Email + ContactEmailMiniForm(state), + + /// Phone + ContactPhoneMiniForm(state), + + /// Twitter + ContactSocialMiniForm(state, SocialActivityType.Twitter), + + /// Git + ContactSocialMiniForm(state, SocialActivityType.Git), + + /// Address + ContactAddressMiniForm(state), + + /// Job + ContactJobMiniForm(state), + + /// BIRTHDAY + ContactBirthdayMiniForm(state), + + /// EVENTS + ContactEventsMiniForm(state), + + /// Links + ContactWebsiteMiniForm(state), + + /// NOTES + ContactNotesMiniForm(state), + + /// Relationships, set a smaller maxDropdownHeight since we're near the bottom of the view + ContactRelationshipMiniForm(state, maxDropdownHeight: 140), + + if (!contact.isNew) + BaseStyledBtn( + hoverColor: theme.isDark? ColorUtils.shiftHsl(theme.bg1, .2) : theme.bg2.withOpacity(.35), + child: Text("DELETE THIS CONTACT", style: TextStyles.T1.textColor(theme.error)), + onPressed: state.handleDeletePressed, + ).padding(vertical: Insets.m), + + //Add some extra padding at the bottom to account for the Relationship Dropdown menu + VSpace(30), + ], + ).padding(horizontal: Insets.l, bottom: Insets.m) + ], + ), + ).flexible(), + SizedBox(height: Insets.m), + ], + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/expanding_miniform_container.dart b/flokk_src/lib/views/contact_edit/expanding_miniform_container.dart new file mode 100644 index 0000000..9349757 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/expanding_miniform_container.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +/// [FocusChangedNotification] Dispatched from a mini-form up into the ExpandingFormContainer, +/// allowing the Container to track focus state +class FocusChangedNotification extends Notification { + final bool isFocused; + + FocusChangedNotification(this.isFocused); +} + +/// [FocusChangedNotification] Dispatched from a mini-form up indicating we should close the container +class CloseFormNotification extends Notification {} + +typedef Widget FormBuilder(); +typedef bool BoolCallback(); + +/// ///////////////////////////////////////////////////// +/// [ExpandingMiniformContainer] - Holds a textfield prompt that opens into a form, listens for focus [FocusChangedNotification] +/// and auto-closes the container after a certain amt of time un-focused +class ExpandingMiniformContainer extends StatefulWidget { + final AssetImage icon; + final String sectionType; + final String activeSectionType; + final BoolCallback hasContent; + final FormBuilder formBuilder; + final Function(String) onOpened; + final bool autoFocus; + + const ExpandingMiniformContainer(this.sectionType, this.icon, + {Key key, this.activeSectionType, this.hasContent, this.formBuilder, this.onOpened, this.autoFocus = false}) + : assert((icon is AssetImage) || (icon is IconData)), + super(key: key); + + @override + _ExpandingMiniformContainerState createState() => _ExpandingMiniformContainerState(); +} + +class _ExpandingMiniformContainerState extends State with TickerProviderStateMixin { + bool _isOpen; + String _hint; + Timer timer; + + set isOpen(bool value) => setState(() => _isOpen = value); + + void _handlePromptFocusChanged(v) { + if (v == false) return; + // FIX: Unfocus any current textfields otherwise Flutter seems to randomly miss the new autofocus when its added to the tree + Utils.unFocus(); + // Show the form widget + Future.delayed(1.milliseconds, () => isOpen = true); + widget.onOpened?.call(widget.sectionType); + } + + bool _handleFormFocusChanged(bool value) { + if (value == false) { + timer?.cancel(); + timer = Timer(Duration(milliseconds: 750), () { + if (widget.hasContent()) return; + isOpen = false; + }); + } else { + timer?.cancel(); + } + return true; + } + + @override + Widget build(BuildContext context) { + timeDilation = 1; // + _isOpen ??= widget.hasContent() || widget.autoFocus; + switch (widget.sectionType) { + case "github": + _hint = "Add GitHub ID"; + break; + case "twitter": + _hint = "Add Twitter ID"; + break; + default: + _hint = "Add ${widget.sectionType}"; + break; + } + AppTheme theme = context.watch(); + + return NotificationListener( + // Listen for FocusNotifications from the child mini-form, + // This way we can track our focus state without coupling directly to the mini-form + onNotification: (n) => _handleFormFocusChanged(n.isFocused), + child: NotificationListener( + onNotification: (n) => isOpen = false, + child: AnimatedSize( + alignment: Alignment.topLeft, + curve: Curves.easeOut, + duration: Durations.fast, + vsync: this, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + /// Left Icon + StyledImageIcon(widget.icon, size: 20, color: theme.grey).translate(offset: Offset(0, 8)), + HSpace(Insets.l), + + /// Content - Either the miniform, or the StyledText + (_isOpen + // Mini-Form + ? widget.formBuilder() + // Empty Prompt Text + : StyledFormTextInput( + hintText: _hint, onFocusChanged: _handlePromptFocusChanged) + .padding(right: Insets.l * 1.5 - 2)) + .padding(right: Insets.m) + .flexible(), + ], + ), + ), + ), + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/address_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/address_miniform.dart new file mode 100644 index 0000000..27dbab6 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/address_miniform.dart @@ -0,0 +1,98 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactAddressMiniForm extends BaseMiniForm { + ContactAddressMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.address, key: key); + + @override + Widget build(BuildContext context) { + List types = ["Home", "Work", "Other"]; + return buildExpandingContainer( + StyledIcons.address, + hasContent: () => c.hasAddress, + formBuilder: () { + /// Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) { + if (c.addressList.isEmpty) c.addressList.add(AddressData()); + List kids = c.addressList.map((a) { + return SeparatedColumn(key: ObjectKey(a), + separatorBuilder: ()=>VSpace(Insets.xs), + children: [ + /// Street + Type + buildTextWithDropdown( + context, + null, + autoFocus: getIsFocused(c.addressList, a), + hint: "Street", + typeHint: "Type", + initialText: a.singleLineStreet, + initialType: a.type, + types: types.map((e) => e.toUpperCase()).toList(), + onTextChanged: (value) => setFormState(() => a.street = value), + onTypeChanged: (value) => setFormState(() => a.type = value), + onDelete: () => handleDeletePressed(context, a, c.addressList), + showDelete: !a.isEmpty, + typeWidth: 160, + ), + + /// City / State + buildDualTextInput( + context, + "City", + a.city, + (v) => setFormState(() => a.city = v), + "State", + a.region, + (v) => setFormState(() => a.region = v), + ).padding(right: rightPadding), + + /// Country / Postal Code + buildDualTextInput( + context, + "Postal Code", + a.postcode, + (v) => setFormState(() => a.postcode = v), + "Country", + a.country, + (v) => setFormState(() => a.country = v), + ).padding(right: rightPadding), +//TODO: Put the country-dropdown back in when we have time to debug the height issue. +// buildTextWithDropdown( +// context, +// null, +// hint: "Postal Code", +// typeHint: "Country", +// initialText: a.postcode, +// initialType: a.country, +// types: Countries.all.map((e) => e.toUpperCase()).toList(), +// onTextChanged: (value) => setFormState(() => a.postcode = value), +// onTypeChanged: (value) => setFormState(() => a.country = value), +// showDelete: false, +// typeWidth: 160, +// ), + + if (c.addressList.indexOf(a) < c.addressList.length - 1) VSpace(Insets.m), + ]); + }).toList(); + + /// Maybe add addNew btn + injectAddNewBtnIfNecessary( + "Add $sectionType", kids, c.addressList, (a) => a.isEmpty, () => AddressData()); + return SeparatedColumn( + separatorBuilder: ()=>VSpace(Insets.sm), + crossAxisAlignment: CrossAxisAlignment.start, + children: kids); + }, + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/base_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/base_miniform.dart new file mode 100644 index 0000000..c29e760 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/base_miniform.dart @@ -0,0 +1,224 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/styled_autocomplete_dropdown.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/expanding_miniform_container.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// ///////////////////////////////////////////////////// +/// [BaseMiniForm] Provides common methods and component builders for all mini-forms +/// [SB] Currently this class basically just provides convenience methods, and buildFxns, +/// so you don't need to dispatch FocusNotifications in your concrete MiniForms. +/// It's composed mainly of sub-builds methods, which should probably be refactored into +/// individual FormWidgets, but it works for now. +abstract class BaseMiniForm extends StatelessWidget { + BaseMiniForm(this.form, this.sectionType, {Key key}) : super(key: key); + + double get rightPadding => Insets.l * 1.5 - 2; + + final String sectionType; + + final ContactEditFormState form; + + bool get isSelected => form.currentSection == sectionType; + + ContactData get c => form.tmpContact; + + bool getIsFocused(List list, T item) => isSelected && list.indexOf(item) == 0; + + /// ///////////////////////////////////////////////////// + /// SHARED HANDLERS AND BUSINESS LOGIC + void setFormState(Function() action) { + action(); + form.rebuild(); + } + + void handleFocusChanged(bool value, BuildContext context) => FocusChangedNotification(value).dispatch(context); + + void handleDeletePressed(BuildContext context, T item, List list) { + // Remove item + list.remove(item); + // Rebuild form + + //Manually request a close if we're empty + (list.isEmpty) ? CloseFormNotification().dispatch(context) : form.rebuild(); + } + + void handleAddPressed(T item, List list) { + list.add(item); + form.rebuild(); + } + + /// ///////////////////////////////////////////////////// + /// SHARED UI FACTORY BUILD METHODS + + /// Adds a build button if the final row in the list has some content, and we don't exceed some max # of items + void injectAddNewBtnIfNecessary( + String hint, List column, List list, Function(T) isEmpty, Function() itemBuilder) { + int maxItems = 8; + if (list.isNotEmpty && !isEmpty(list.last) && list.length < maxItems) { + Widget btn = TransparentIconAndTextBtn( + "Add $sectionType", + StyledIcons.formAdd, + bigMode: true, + textColor: form.theme.greyWeak, + onPressed: () => handleAddPressed(itemBuilder(), list), + ).translate(offset: Offset(-4, 0)); + // Wrap the button in a row with a spacer, so it will not stretch all the way across the form + column.add(Row(children: [btn, Spacer()])); + } + } + + /// ////////////////////////////////////////////////////////////// + /// Creates an ExpandingMiniformContainer with some shared boilerplate (auto-focus check, and onOpened handler). + /// Every miniform calls this fxn. + Widget buildExpandingContainer(dynamic icon, {BoolCallback hasContent, FormBuilder formBuilder}) { + return ExpandingMiniformContainer( + sectionType, + icon, + hasContent: hasContent, + formBuilder: formBuilder, + // Auto-focus if we're the current form section + autoFocus: form.currentSection == sectionType, + // When we open, let the form know that we're the current section + onOpened: (s) => form?.handleSectionChanged(s), + ); + } + + /// ////////////////////////////////////////////////// + /// Builds a basic TextInput that dispatches focusChanged + /// //TODO SB: Move this and the other components into their own widgets. They just need to be passed the miniform as a component. + Widget buildTextInput(BuildContext context, String hint, String initial, Function(String) onChanged, + {bool autoFocus = false, EdgeInsets padding, int maxLines = 1, TextEditingController controller}) { + return StyledFormTextInput( + controller: controller, + hintText: hint, + contentPadding: padding ?? StyledFormTextInput.kDefaultTextInputPadding, + autoFocus: autoFocus, + initialValue: initial, + maxLines: maxLines, + onChanged: onChanged, + // Let parent widget know when textfield focus has changed + onFocusChanged: (v) => handleFocusChanged(v, context)); + } + + /// ////////////////////////////////////////////////// + /// Builds a dual-column TextInput like those used in Address + Widget buildDualTextInput(BuildContext context, String hint1, String initial1, Function(String) onChanged1, + String hint2, String initial2, Function(String) onChanged2, + {bool autoFocus = false, EdgeInsets padding, int maxLines = 1}) { + return Row( + children: [ + buildTextInput(context, hint1, initial1, onChanged1, autoFocus: autoFocus, padding: padding, maxLines: maxLines) + .flexible(), + HSpace(Insets.m), + buildTextInput(context, hint2, initial2, onChanged2, padding: padding, maxLines: maxLines).flexible(), + ], + ); + } + + /// /////////////////////////////////////////////////// + /// Builds a single-row widget that is a combination of Text and AutoCompleteDropdown + /// This is your classic EMAIL / TYPE DROPDOWN field + Widget buildTextWithDropdown(BuildContext context, dynamic item, + {String hint, + String typeHint, + String initialText, + String initialType, + List types, + Function(String) onTextChanged, + Function(String) onTypeChanged, + Function() onDelete, + bool showDelete, + bool autoFocus = false, + double maxDropdownHeight = 300, + double typeWidth = 100}) { + return Row( + key: item != null ? ObjectKey(item) : null, + children: [ + /// Text Input + buildTextInput( + context, + hint, + initialText, + onTextChanged, + autoFocus: autoFocus, + ).flexible(), + HSpace(Insets.m), + + /// Type dropdown + StyledAutoCompleteDropdown( + items: types, + hint: typeHint, + initialValue: initialType, + onChanged: onTypeChanged, + maxHeight: maxDropdownHeight, + onFocusChanged: (v) => handleFocusChanged(v, context)).width(typeWidth).translate(offset: Offset(0, 3)), + HSpace(2), + + /// Delete Btn + ColorShiftIconBtn( + StyledIcons.formDelete, + size: 20, + onPressed: showDelete ? onDelete : null, + padding: EdgeInsets.all(Insets.sm), + ).opacity(showDelete ? 1 : 0, animate: true).animate(Durations.fast, Curves.linear), + ], + ); + } + + /// //////////////////////////////////////////////////////////// + /// Build a column of Text/Type dropdowns, from a list of items + buildColumnOfTextWithDropdown( + BuildContext context, + String hint, + String typeHint, { + List itemList, + List types, + T Function() newItemBuilder, + bool Function(T) isEmpty, + String Function(T) getValue, + Function(T, String) setValue, + String Function(T) getType, + Function(T, String) setType, + double maxDropdownHeight, + }) { + //If we've been given an empty list, populate it with at least one item. + if (itemList.isEmpty) itemList.add(newItemBuilder()); + + /// Build a list of rows for each item in the list + List kids = itemList.map((item) { + // Create a TextAndTypeRow widget + return buildTextWithDropdown(context, item, + autoFocus: getIsFocused(itemList, item), + hint: hint, + typeHint: typeHint, + initialText: getValue(item), + initialType: getType(item), + types: types.map((e) => e.toUpperCase()).toList(), + onTextChanged: (value) => setFormState(() => setValue(item, value)), + onTypeChanged: (value) => setFormState(() => setType(item, value)), + onDelete: () => handleDeletePressed(context, item, itemList), + showDelete: !isEmpty(item), + maxDropdownHeight: maxDropdownHeight); + }).toList(); + + /// Add a "Add New" btn to the column if certain conditions are met + injectAddNewBtnIfNecessary(hint, kids, itemList, isEmpty, newItemBuilder); + + /// Return the actual Column of content + return SeparatedColumn( + separatorBuilder: () => VSpace(Insets.sm), + crossAxisAlignment: CrossAxisAlignment.start, + children: kids, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/birthday_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/birthday_miniform.dart new file mode 100644 index 0000000..d5aef19 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/birthday_miniform.dart @@ -0,0 +1,29 @@ +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/controls/textfield_with_date_picker_row.dart'; +import 'package:flutter/material.dart'; + +class ContactBirthdayMiniForm extends BaseMiniForm { + ContactBirthdayMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.birthday, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.birthday, + hasContent: () => c.hasBirthday, + formBuilder: () { + return TextfieldWithDatePickerRow( + this, + hint: "Birthday", + isSelected: isSelected, + initialValue: c.birthday.text, + onDateChanged: (string, date) { + c.birthday.text = string; + c.birthday.date = date; + }, + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/controls/textfield_with_date_picker_row.dart b/flokk_src/lib/views/contact_edit/miniforms/controls/textfield_with_date_picker_row.dart new file mode 100644 index 0000000..a19b66e --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/controls/textfield_with_date_picker_row.dart @@ -0,0 +1,104 @@ +import 'package:flokk/_internal/utils/date_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/secondary_btn.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_edit/expanding_miniform_container.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + + +class TextfieldWithDatePickerRow extends StatefulWidget { + final BaseMiniForm miniform; + final bool isSelected; + final String hint; + final String initialValue; + final void Function(String, DateTime) onDateChanged; + + const TextfieldWithDatePickerRow( + this.miniform, { + Key key, + this.isSelected, + this.hint, + this.initialValue, + this.onDateChanged, + }) : super(key: key); + + @override + _TextfieldWithDatePickerRowState createState() => _TextfieldWithDatePickerRowState(); +} + +class _TextfieldWithDatePickerRowState extends State { + + TextEditingController textController; + + void handleDatePicked(BuildContext context) async { + DateTime firstDate = DateTime.now().subtract((365 * 100).days); + DateTime lastDate = DateTime.now().add((365 * 10).days); + DateTime startDate; + + ///Parse the current date text, we can have no idea if this is a valid date or not + startDate = parseDate(textController.text); + + /// Manually 'clamp' these dates because material date picker likes to blow up with AssertErrors rather than fail gracefully + if (startDate.isBefore(firstDate)) startDate = firstDate; + if (startDate.isAfter(lastDate)) startDate = lastDate; + DateTime result = await showDatePicker( + context: context, + initialDate: startDate, + firstDate: firstDate, + lastDate: lastDate, + ); + if (result != null) { + textController.text = DateFormats.google.format(result); + widget.onDateChanged(textController.text, result); + } + } + + DateTime parseDate(String v) { + try { + return DateFormats.google.parse(v); + } on FormatException catch (e) { + return DateTime.now(); + } + } + + @override + void initState() { + textController = TextEditingController(text: widget.initialValue); + super.initState(); + } + + _handleFocusChanged(bool value) => FocusChangedNotification(value).dispatch(context); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return Stack( + children: [ + Container( + margin: EdgeInsets.only(right: 80), + child: widget.miniform.buildTextInput( + context, + widget.hint, + widget.initialValue, + (v) => widget.onDateChanged?.call(v, parseDate(v)), + autoFocus: widget.isSelected, + controller: textController, + ), + ), + SecondaryBtn( + onPressed: () => handleDatePicked(context), + minHeight: 20, + contentPadding: Insets.sm, + onFocusChanged: _handleFocusChanged, + child: StyledImageIcon(StyledIcons.calendar, color: theme.accent1), + ).positioned(right: 0, bottom: 3), + ], + ); + } + +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/email_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/email_miniform.dart new file mode 100644 index 0000000..b2be859 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/email_miniform.dart @@ -0,0 +1,31 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactEmailMiniForm extends BaseMiniForm { + ContactEmailMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.email, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.mail, + hasContent: () => c.hasEmail, + formBuilder: () { + // Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => buildColumnOfTextWithDropdown(context, "Email Address", "Type", + itemList: c.emailList, + types: ["Home", "Work", "Other"], + newItemBuilder: () => EmailData(), + isEmpty: (EmailData i) => i.isEmpty, + getValue: (i) => i.value, + setValue: (i, value) => i.value = value, + getType: (i) => i.type, + setType: (i, type) => i.type = type), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/events_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/events_miniform.dart new file mode 100644 index 0000000..ac00e4b --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/events_miniform.dart @@ -0,0 +1,102 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/date_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/styled_autocomplete_dropdown.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flokk/views/contact_edit/miniforms/controls/textfield_with_date_picker_row.dart'; +import 'package:flutter/material.dart'; + +class ContactEventsMiniForm extends BaseMiniForm { + ContactEventsMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.events, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.calendar, + hasContent: () => c.hasEvents, + formBuilder: () { + //If we've been given an empty list, populate it with at least one item. + var itemList = c.eventList; + EventData newItemBuilder() => EventData()..date = DateTime.now(); + if (itemList.isEmpty) itemList.add(newItemBuilder()); + + /// Build a list of rows for each item in the list + List kids = itemList.map((item) { + // Create a TextAndTypeRow widget + return buildTextWithDatePickerAndDropdown(context, item, + autoFocus: getIsFocused(itemList, item), + hint: "Event", + typeHint: "Type", + initialText: DateFormats.google.format(item.date), + initialType: item.type, + types: ["Anniversary", "Hire Date", "Other"].map((e) => e.toUpperCase()).toList(), + onDateChanged: (s, d) => setFormState(() => item.date = d), + onTypeChanged: (value) => setFormState(() => item.type = value), + onDelete: () => handleDeletePressed(context, item, itemList), + showDelete: !item.isEmpty); + }).toList(); + + /// Add a "Add New" btn to the column if certain conditions are met + injectAddNewBtnIfNecessary("Event", kids, itemList, (e) => e.isEmpty, newItemBuilder); + + /// Return the actual Column of content + return SeparatedColumn( + separatorBuilder: ()=>VSpace(Insets.sm * 1.5), + crossAxisAlignment: CrossAxisAlignment.start, + children: kids, + ); + }, + ); + } + + Widget buildTextWithDatePickerAndDropdown(BuildContext context, dynamic item, + {String hint, + String typeHint, + String initialText, + String initialType, + List types, + Function(String, DateTime) onDateChanged, + Function(String) onTypeChanged, + Function() onDelete, + bool showDelete, + bool autoFocus = false, + double typeWidth = 100}) { + return Row( + key: item != null ? ObjectKey(item) : null, + children: [ + /// Text Input + TextfieldWithDatePickerRow( + this, + hint: hint, + initialValue: initialText, + onDateChanged: onDateChanged, + isSelected: autoFocus, + ).flexible(), + HSpace(Insets.m), + + /// Type dropdown + StyledAutoCompleteDropdown( + items: types, + hint: typeHint, + initialValue: initialType, + onChanged: onTypeChanged, + onFocusChanged: (v) => handleFocusChanged(v, context)).width(typeWidth).translate(offset: Offset(0, 3)), + HSpace(2), + + /// Delete Btn + ColorShiftIconBtn( + StyledIcons.formDelete, + size: 20, + onPressed: showDelete ? onDelete : null, + padding: EdgeInsets.all(Insets.sm), + ).opacity(showDelete ? 1 : 0, animate: true).animate(Durations.fast, Curves.linear), + ], + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/job_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/job_miniform.dart new file mode 100644 index 0000000..0c91a37 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/job_miniform.dart @@ -0,0 +1,29 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactJobMiniForm extends BaseMiniForm { + ContactJobMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.job, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.work, + hasContent: () => c.hasJob, + formBuilder: () { + /// Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => SeparatedColumn( + children: [ + buildTextInput(context, "Company", c.jobCompany, (v) => c.jobCompany = v, autoFocus: isSelected), + buildTextInput(context, "Job Title", c.jobTitle, (v) => c.jobTitle = v), + ], + ).padding(right: rightPadding), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/label_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/label_miniform.dart new file mode 100644 index 0000000..7dfa76f --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/label_miniform.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/groups/create_label_command.dart'; +import 'package:flokk/data/group_data.dart'; +import 'package:flokk/styled_components/styled_form_label_input.dart'; +import 'package:flokk/styled_components/styled_group_label.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactLabelMiniForm extends BaseMiniForm { + ContactLabelMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.label, key: key); + + void _handleAddLabel(String label, BuildContext context) { + // If the label is empty or we already have that label on our contact then dont add it + if (label.isEmpty || c.groupList.any((g) => g.name == label)) return; + //TODO SB@CE - This (form.widget.contactsModel) is probably ok, since these miniforms are tightly coupled to form. But no need to reach out for contactsModel. Instead just look it up with provider: ContactsModel contactsModel = context.watch(); + GroupData groupToAdd = form.widget.contactsModel.getGroupByName(label); + if (groupToAdd != null) { + setFormState(() => c.groupList.add(groupToAdd)); + } else { + // We must make a new group, add that group to this contact when the creation has finished + CreateLabelCommand(context).execute(label).then((g) => setFormState(() => c.groupList.add(g))); + } + } + + void _handleRemoveLabel(String label) { + setFormState(() => c.groupList.removeWhere((g) => g.name == label)); + } + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.label, + hasContent: () => c.hasLabel, + formBuilder: () { + // Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) { + return _LabelMiniformWithSearch( + autoFocus: isSelected, + onAddLabel: (label) => _handleAddLabel(label, context), + onRemoveLabel: _handleRemoveLabel, + contactLabels: c.groupList.map((g) => g.name).toList(), + allLabels: form.widget.contactsModel.allGroups.map((g) => g.name).toList(), + onFocusChanged: (v) => handleFocusChanged(v, context), + ).padding(right: rightPadding); + }, + ); + }, + ); + } +} + +class _LabelMiniformWithSearch extends StatefulWidget { + final bool autoFocus; + final void Function(String) onAddLabel; + final void Function(String) onRemoveLabel; + final void Function(bool) onFocusChanged; + final List contactLabels; + final List allLabels; + + _LabelMiniformWithSearch({ + this.autoFocus, + this.onAddLabel, + this.onRemoveLabel, + this.onFocusChanged, + this.contactLabels, + this.allLabels, + }); + + @override + _LabelMiniformWithSearchState createState() => _LabelMiniformWithSearchState(); +} + +class _LabelMiniformWithSearchState extends State<_LabelMiniformWithSearch> { + bool _isOpen; + String _labelFilter = ""; + Timer _timer; + + _LabelMiniformWithSearchState(); + + @override + void didUpdateWidget(_LabelMiniformWithSearch oldWidget) { + super.didUpdateWidget(oldWidget); + } + + void _handleOnChanged(String filter) { + setState(() { + _labelFilter = filter; + }); + } + + bool _handleFocusChanged(bool value) { + if (value == false) { + _timer?.cancel(); + _timer = Timer(Duration(milliseconds: 750), () { + setState(() => _isOpen = false); + }); + } else { + _timer?.cancel(); + } + widget.onFocusChanged(value); + return true; + } + + bool _handleTextFocusChanged(bool value) { + final result = _handleFocusChanged(value); + if (value && !_isOpen) setState(() => _isOpen = true); + return result; + } + + @override + Widget build(BuildContext context) { + _isOpen ??= widget.autoFocus; + AppTheme theme = context.watch(); + final searchResults = widget.allLabels + .where((l) => l.toLowerCase().contains(_labelFilter.toLowerCase())) + .where((l) => !widget.contactLabels.contains(l)) + .take(6) + .map( + (l) => StyledGroupLabel( + text: l, + onPressed: () => widget.onAddLabel(l), + onFocusChanged: _handleFocusChanged, + ), + ) + .toList(); + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + StyledFormLabelInput( + hintText: "Labels", + autoFocus: widget.autoFocus, + onAddLabel: widget.onAddLabel, + onRemoveLabel: widget.onRemoveLabel, + onChanged: _handleOnChanged, + labels: widget.contactLabels, + onFocusChanged: _handleTextFocusChanged, + ), + if (_isOpen) ...{ + Text("Suggestions".toUpperCase(), style: TextStyles.Caption.textColor(theme.grey)).padding(bottom: Insets.m), + Wrap( + runSpacing: Insets.sm * 1.5, + spacing: Insets.sm, + children: searchResults, + ), + }, + ]); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/name_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/name_miniform.dart new file mode 100644 index 0000000..2f66712 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/name_miniform.dart @@ -0,0 +1,35 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactNameMiniForm extends BaseMiniForm { + ContactNameMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.name, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.user, + hasContent: () => c.hasName, + formBuilder: () { + /// Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => SeparatedColumn( + separatorBuilder: ()=>VSpace(Insets.sm * .5), + children: [ + buildTextInput(context, "First Name", c.nameGiven, (v) => c.nameGiven = v, autoFocus: isSelected), + buildTextInput(context, "Middle Name", c.nameMiddle, (v) => c.nameMiddle = v), + buildTextInput(context, "Last Name", c.nameFamily, (v) => c.nameFamily = v), + buildDualTextInput(context, "Prefix", c.namePrefix, (v) => c.namePrefix = v, "Suffix", c.nameSuffix, + (v) => c.nameSuffix = v), + ], + ).padding(right: rightPadding), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/notes_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/notes_miniform.dart new file mode 100644 index 0000000..380d46c --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/notes_miniform.dart @@ -0,0 +1,35 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactNotesMiniForm extends BaseMiniForm { + ContactNotesMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.notes, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.note, + hasContent: () => c.hasNotes, + formBuilder: () { + /// Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => SeparatedColumn( + children: [ + buildTextInput( + context, + "Notes", + c.notes, + (v) => c.notes = v, + autoFocus: isSelected, + maxLines: null, + ) + ], + ).padding(right: rightPadding), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/phone_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/phone_miniform.dart new file mode 100644 index 0000000..532475f --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/phone_miniform.dart @@ -0,0 +1,31 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactPhoneMiniForm extends BaseMiniForm { + ContactPhoneMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.phone, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.phone, + hasContent: () => c.hasPhone, + formBuilder: () { + /// Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => buildColumnOfTextWithDropdown(context, "Phone Number", "Type", + itemList: c.phoneList, + types: ["Work", "Other", "Mobile", "Main", "Home Fax", "Work Fax", "Google Voice", "Pager"], + newItemBuilder: () => PhoneData(), + isEmpty: (PhoneData i) => i.isEmpty, + getValue: (i) => i.number, + setValue: (i, value) => i.number = value, + getType: (i) => i.type, + setType: (i, type) => i.type = type), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/relationship_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/relationship_miniform.dart new file mode 100644 index 0000000..aa5151a --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/relationship_miniform.dart @@ -0,0 +1,54 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactRelationshipMiniForm extends BaseMiniForm { + double maxDropdownHeight; + + ContactRelationshipMiniForm(ContactEditFormState form, {Key key, this.maxDropdownHeight}) + : super(form, ContactSectionType.relationship, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.relationship, + hasContent: () => c.hasRelationship, + formBuilder: () { + // Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => buildColumnOfTextWithDropdown( + context, + "Person", + "Relationship", + maxDropdownHeight: maxDropdownHeight, + itemList: c.relationList, + newItemBuilder: () => RelationData(), + isEmpty: (RelationData i) => i.isEmpty, + getValue: (i) => i.person, + setValue: (i, value) => i.person = value, + getType: (i) => i.type, + setType: (i, type) => i.type = type, + types: [ + "Spouse", + "Child", + "Mother", + "Father", + "Parent", + "Brother", + "Sister", + "Friend", + "Relative", + "Manager", + "Assistant", + "Reference", + "Partner", + "Domestic Partner" + ], + ), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/social_miniforms.dart b/flokk_src/lib/views/contact_edit/miniforms/social_miniforms.dart new file mode 100644 index 0000000..44aba83 --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/social_miniforms.dart @@ -0,0 +1,58 @@ +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/social_activity_type.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactSocialMiniForm extends BaseMiniForm { + final SocialActivityType type; + + ContactSocialMiniForm(ContactEditFormState form, this.type, {Key key}) + : super(form, type == SocialActivityType.Git ? ContactSectionType.github : ContactSectionType.twitter, key: key); + + bool get isGit => type == SocialActivityType.Git; + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + isGit ? StyledIcons.githubActive : StyledIcons.twitterActive, + hasContent: isGit ? () => c.hasGit : () => c.hasTwitter, + formBuilder: () { + // Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder(builder: (context) { + if (type == SocialActivityType.Git) { + return buildPrefixedTextInput(context, "github.com/", c.gitUsername, (v) => c.gitUsername = v); + } else { + return buildPrefixedTextInput(context, "@", c.twitterHandle, (v) => c.twitterHandle = v); + } + }).padding(right: rightPadding); + }, + ); + } + + /// Builds prefixed TextInput used for git/twitter + Widget buildPrefixedTextInput(BuildContext context, String hint, String initial, Function(String) onChanged, + [bool autoFocus = false]) { + double prefixSize = StringUtils.measure(hint, TextStyles.Body1).width; + EdgeInsets padding = StyledFormTextInput.kDefaultTextInputPadding.copyWith(left: prefixSize + .5); + return FocusTraversalGroup( + child: Stack( + children: [ + /// Prefix text, non-interactive + IgnorePointer( + child: FocusScope( + canRequestFocus: false, + child: buildTextInput(context, hint, "", (v) {})), + ), + + /// Value text + buildTextInput(context, "", initial, onChanged, autoFocus: isSelected, padding: padding), + ], + ), + ); + } +} diff --git a/flokk_src/lib/views/contact_edit/miniforms/website_miniform.dart b/flokk_src/lib/views/contact_edit/miniforms/website_miniform.dart new file mode 100644 index 0000000..7ac3aae --- /dev/null +++ b/flokk_src/lib/views/contact_edit/miniforms/website_miniform.dart @@ -0,0 +1,31 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_edit/miniforms/base_miniform.dart'; +import 'package:flutter/material.dart'; + +class ContactWebsiteMiniForm extends BaseMiniForm { + ContactWebsiteMiniForm(ContactEditFormState form, {Key key}) : super(form, ContactSectionType.websites, key: key); + + @override + Widget build(BuildContext context) { + return buildExpandingContainer( + StyledIcons.link, + hasContent: () => c.hasLink, + formBuilder: () { + // Wrap content in a builder so the FocusNotification will get caught by the ExpandingFormContainer + return Builder( + builder: (context) => buildColumnOfTextWithDropdown(context, "Link", "Type", + itemList: c.websiteList, + types: ["Blog", "Home Page", "Profile", "Work"], + newItemBuilder: () => WebsiteData(), + isEmpty: (WebsiteData i) => i.isEmpty, + getValue: (i) => i.href, + setValue: (i, value) => i.href = value, + getType: (i) => i.type, + setType: (i, type) => i.type = type), + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_info/contact_info_details_card.dart b/flokk_src/lib/views/contact_info/contact_info_details_card.dart new file mode 100644 index 0000000..ff993f6 --- /dev/null +++ b/flokk_src/lib/views/contact_info/contact_info_details_card.dart @@ -0,0 +1,117 @@ +import 'package:flokk/_internal/url_launcher/url_launcher.dart'; +import 'package:flokk/_internal/utils/date_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/clickable_icon_row.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class ContactInfoDetailsCard extends StatelessWidget { + const ContactInfoDetailsCard({Key key}) : super(key: key); + + void _handlePhonePressed(String value) => UrlLauncher.openPhoneNumber(value); + + void _handleEmailPressed(String value) => UrlLauncher.openEmail(value); + + void _handleLocationPressed(String value) => UrlLauncher.openGoogleMaps(value); + + void _handleGitPressed(String value) => UrlLauncher.openGitUser(value); + + void _handleTwitterPressed(String value) => UrlLauncher.openTwitterUser(value); + + void _handleLinkPressed(String value) => UrlLauncher.openHttp(value); + + @override + Widget build(BuildContext context) { + /// /////////////////////////////////////////////// + /// Bind to provided contact + ContactData contact = context.watch(); + + return Column( + children: [ + /// EMAIL + if (contact.hasEmail) + MultilineClickableIconRow( + icon: StyledIcons.mail, + onPressed: _handleEmailPressed, + editType: ContactSectionType.email, + rows: contact.emailList.map((e) => Tuple2(e.value, e.type)).toList(), + ), + + /// PHONE + if (contact.hasPhone) + MultilineClickableIconRow( + icon: StyledIcons.phone, + onPressed: _handlePhonePressed, + editType: ContactSectionType.phone, + rows: contact.phoneList.map((e) => Tuple2(e.number, e.type)).toList(), + ), + + /// SOCIAL + if (contact.hasGit) + ClickableIconRow( + icon: StyledIcons.githubActive, + onPressed: _handleGitPressed, + value: "github.com/${contact.gitUsername}", + editType: ContactSectionType.github, + ), + if (contact.hasTwitter) + ClickableIconRow( + icon: StyledIcons.twitterActive, + onPressed: _handleTwitterPressed, + value: "@${contact.twitterHandle}", + editType: ContactSectionType.twitter, + ), + + /// ADDRESS + if (contact.hasAddress) + MultilineClickableIconRow( + icon: StyledIcons.address, + onPressed: _handleLocationPressed, + rows: contact.addressList.map((a) => Tuple2(a.getFullAddress(), a.type)).toList(), + editType: ContactSectionType.address, + ), + + /// Job + if (contact.hasJob) + ClickableIconRow(icon: StyledIcons.work, value: contact.formattedJob, editType: ContactSectionType.job), + + /// BIRTHDAY + if (contact.hasBirthday) + ClickableIconRow( + icon: StyledIcons.birthday, value: contact.birthday.text, editType: ContactSectionType.birthday), + + /// Events + MultilineClickableIconRow( + icon: StyledIcons.calendar, + rows: contact.eventList.map((d) => Tuple2(DateFormats.google.format(d.date), d.type)).toList(), + editType: ContactSectionType.events, + ), + + /// LINKS + if (contact.hasLink) + MultilineClickableIconRow( + icon: StyledIcons.link, + onPressed: _handleLinkPressed, + rows: contact.websiteList.map((a) => Tuple2(a.href, a.type)).toList(), + editType: ContactSectionType.websites), + + /// NOTES + if (contact.hasNotes) + ClickableIconRow(icon: StyledIcons.note, value: contact.notes, editType: ContactSectionType.notes), + + /// RELATIONSHIP + if (contact.hasRelationship) + MultilineClickableIconRow( + icon: StyledIcons.relationship, + rows: contact.relationList.map((a) => Tuple2(a.person, a.type)).toList(), + editType: ContactSectionType.relationship, + ), + ], + ).padding(top: Insets.m * 1.5, bottom: 50); + } +} diff --git a/flokk_src/lib/views/contact_info/contact_info_header_card.dart b/flokk_src/lib/views/contact_info/contact_info_header_card.dart new file mode 100644 index 0000000..64eb3af --- /dev/null +++ b/flokk_src/lib/views/contact_info/contact_info_header_card.dart @@ -0,0 +1,91 @@ +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/contacts/toggle_favorite_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/styled_group_label.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_user_avatar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactInfoHeaderCard extends StatefulWidget { + const ContactInfoHeaderCard({Key key}) : super(key: key); + + @override + _ContactInfoHeaderCardState createState() => _ContactInfoHeaderCardState(); +} + +class _ContactInfoHeaderCardState extends State { + void handleStarPressed(ContactData c) { + ToggleFavoriteCommand(context).execute(c); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + /// /////////////////////////////////////////////// + /// Bind to provided contact + ContactData contact = context.watch(); + + AppTheme theme = context.watch(); + + List labels = contact.groupList + .map((e) => StyledGroupLabel(icon: null, text: e.name.toUpperCase()).padding( + horizontal: Insets.sm * .5, + )) + .toList(); + + return SeparatedColumn( + separatorBuilder: () => SizedBox(height: Insets.m * .5), + children: [ + VSpace(Insets.sm - 1), + + /// PROFILE PIC + StyledUserAvatar(key: ValueKey(contact.id), size: 110, contact: contact), + + /// TITLE + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + OneLineText(contact.nameFull, style: TextStyles.H1).flexible(), + HSpace(Insets.sm * .5), + ColorShiftIconBtn( + contact.isStarred ? StyledIcons.starFilled : StyledIcons.starEmpty, + color: contact.isStarred ? theme.accent1 : theme.grey, + onPressed: () => handleStarPressed(contact), + ), + ], + ), + + /// LABELS + if (contact.groupList.isNotEmpty) Wrap(children: labels), + + /// Social Icons + //SocialIconStrip(contact: contact), + ], + ).translate(offset: Offset(0, -Insets.m)); + } +} + +class SocialIconStrip extends StatelessWidget { + final ContactData contact; + final bool vtMode; + + const SocialIconStrip({Key key, this.contact, this.vtMode = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + List widgets = [ + ColorShiftIconBtn(StyledIcons.githubActive), + ColorShiftIconBtn(StyledIcons.twitterActive), + //StyledIconButton(child: Icon(Icons.contacts)), + ]; + return vtMode ? widgets.toColumn(mainAxisSize: MainAxisSize.min) : widgets.toRow(mainAxisSize: MainAxisSize.min); + } +} diff --git a/flokk_src/lib/views/contact_info/contact_info_panel.dart b/flokk_src/lib/views/contact_info/contact_info_panel.dart new file mode 100644 index 0000000..9a8516c --- /dev/null +++ b/flokk_src/lib/views/contact_info/contact_info_panel.dart @@ -0,0 +1,145 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/scrolling/styled_scrollview.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_tab_bar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_info/contact_info_details_card.dart'; +import 'package:flokk/views/contact_info/contact_info_header_card.dart'; +import 'package:flokk/views/contact_info/contact_info_social_card.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactInfoPanel extends StatefulWidget { + final Function() onClosePressed; + final Function(String) onEditPressed; + + const ContactInfoPanel({Key key, this.onClosePressed, this.onEditPressed}) : super(key: key); + + @override + ContactInfoPanelState createState() => ContactInfoPanelState(); +} + +class ContactInfoPanelState extends State { + ContactData _prevContact; + ValueNotifier opacityNotifier = ValueNotifier(0); + + @override + void initState() { + super.initState(); + } + + void startFadeIfContactsHaveChanged(ContactData c) { + if (c != null && c?.id != _prevContact?.id) { + opacityNotifier.value = 0; + Future.microtask(() => opacityNotifier.value = 1); + } + } + + @override + Widget build(BuildContext context) { + /// /////////////////////////////////////////////// + /// Bind to Provided contact + AppTheme theme = context.watch(); + return Consumer( + builder: (_, c, __) { + // Fade in each time we change contact + startFadeIfContactsHaveChanged(c); + //c ??= _prevContact; + if (c != null) _prevContact = c; + return Column( + children: [ + /// TOP ICON ROW + Row(children: [ + ColorShiftIconBtn(StyledIcons.closeLarge, size: 16, color: theme.grey, onPressed: widget.onClosePressed), + Spacer(), + ColorShiftIconBtn(StyledIcons.edit, + size: 22, color: theme.accent1Dark, onPressed: () => widget.onEditPressed(null)), + ]).padding(horizontal: Insets.l), + + /// CONTENT STACK + + ValueListenableBuilder( + valueListenable: opacityNotifier, + builder: (_, value, __) => AnimatedOpacity( + opacity: value, + duration: (value == 0 ? 0 : .35).seconds, + child: StyledScrollView( + child: Column( + + children: [ + VSpace(2), + /// HEADER CARD + ContactInfoHeaderCard(), + VSpace(Insets.l), + + + /// INFO & SOCIAL + _DetailsAndSocialTabView( + onEditPressed: widget.onEditPressed, + ), + ], + ).padding(horizontal: Insets.l), + ), + ), + ).flexible(), + + + ], + ); + }, + ); + } +} + +class _DetailsAndSocialTabView extends StatefulWidget { + final Function(String) onEditPressed; + + const _DetailsAndSocialTabView({Key key, this.onEditPressed}) : super(key: key); + + @override + _DetailsAndSocialTabViewState createState() => _DetailsAndSocialTabViewState(); +} + +class _DetailsAndSocialTabViewState extends State<_DetailsAndSocialTabView> with SingleTickerProviderStateMixin { + TabController tabController; + + void _handleTabPressed(int i) { + AppModel appModel = context.read(); + setState(() => appModel.showSocialTabOnInfoView = i == 1); + } + + @override + void initState() { + //Lookup the starting tab index + int index = context.read().showSocialTabOnInfoView ? 1 : 0; + tabController = TabController(length: 2, vsync: this, initialIndex: index); + super.initState(); + } + + @override + Widget build(BuildContext context) { + // Use .select to bind to the AppModel when showSocialTabOnInfoView changes + int index = context.select((model) => model.showSocialTabOnInfoView) ? 1 : 0; + return Column( + children: [ + StyledTabBar( + index: index, + sections: ["DETAILS", "SOCIAL"], + onTabPressed: _handleTabPressed, + ), + IndexedStack( + index: index, + children: [ + ContactInfoDetailsCard(), + ContactInfoSocialCard(), + ], + ) + ], + ); + } +} diff --git a/flokk_src/lib/views/contact_info/contact_info_social_card.dart b/flokk_src/lib/views/contact_info/contact_info_social_card.dart new file mode 100644 index 0000000..719112b --- /dev/null +++ b/flokk_src/lib/views/contact_info/contact_info_social_card.dart @@ -0,0 +1,76 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/social_contact_data.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/models/github_model.dart'; +import 'package:flokk/models/twitter_model.dart'; +import 'package:flokk/styled_components/scrolling/styled_listview.dart'; +import 'package:flokk/styled_components/social/git_item_renderer.dart'; +import 'package:flokk/styled_components/social/tweet_item_renderer.dart'; +import 'package:flokk/styled_components/styled_card.dart'; +import 'package:flokk/styled_components/styled_progress_spinner.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/empty_states/placeholder_content_switcher.dart'; +import 'package:flokk/views/empty_states/placeholder_git.dart'; +import 'package:flokk/views/empty_states/placeholder_twitter.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactInfoSocialCard extends StatefulWidget { + const ContactInfoSocialCard({Key key}) : super(key: key); + + @override + _ContactInfoSocialCardState createState() => _ContactInfoSocialCardState(); +} + +class _ContactInfoSocialCardState extends State { + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + + /// /////////////////////////////////////////////// + /// Bind to provided contact + ContactData contact = context.watch(); + ContactsModel contactsModel = context.watch(); + bool isGitLoading = context.select((gm) => gm.isLoading); + bool isTwitterLoading = context.select((tm) => tm.isLoading); + SocialContactData social = contactsModel.getSocialById(contact.id); + + int maxItems = 30; + var gitItems = social?.gitEvents?.map((event) => GitEventListItem(event))?.take(maxItems)?.toList() ?? []; + var tweetItems = social?.tweets?.map((tweet) => TweetListItem(tweet))?.take(maxItems)?.toList() ?? []; + + //return Container(); + return Column( + children: [ + (isTwitterLoading + ? StyledCard(bgColor: theme.bg1, child: StyledProgressSpinner()) + : PlaceholderContentSwitcher( + hasContent: () => tweetItems.isNotEmpty, + placeholder: TwitterPlaceholder(contact: contact), + content: StyledListViewWithTitle( + bgColor: theme.bg1.withOpacity(.35), + listItems: tweetItems, + title: "TWITTER RECENT ACTIVITY", + ), + )) + .height(300), + VSpace(Insets.l), + (isGitLoading + ? StyledCard(bgColor: theme.bg1, child: StyledProgressSpinner()) + : PlaceholderContentSwitcher( + hasContent: () => gitItems.isNotEmpty, + placeholder: GitPlaceholder(contact: contact), + content: StyledListViewWithTitle( + bgColor: theme.bg1.withOpacity(.35), + listItems: gitItems, + title: "GITHUB RECENT ACTIVITY", + ), + )) + .height(300), + ], + ).padding(top: Insets.m * 1.5, bottom: Insets.l); + } +} diff --git a/flokk_src/lib/views/contact_page/bulk_contact_edit_bar.dart b/flokk_src/lib/views/contact_page/bulk_contact_edit_bar.dart new file mode 100644 index 0000000..1429c4a --- /dev/null +++ b/flokk_src/lib/views/contact_page/bulk_contact_edit_bar.dart @@ -0,0 +1,85 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/contacts/delete_contact_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/styled_checkbox.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class BulkContactEditBar extends StatefulWidget { + final List checked; + final List all; + final Function() onCheckChanged; + + const BulkContactEditBar({Key key, this.checked, this.onCheckChanged, this.all}) : super(key: key); + + @override + _BulkContactEditBarState createState() => _BulkContactEditBarState(); +} + +class _BulkContactEditBarState extends State { + void _handleCheckChanged(bool value) { + if (value == true) { + widget.checked.clear(); + widget.checked.addAll(widget.all); + } else if (value == false) { + widget.checked.clear(); + } + widget.onCheckChanged?.call(); + } + + void _handleDeletePressed() async { + //Make a copy of the list, so it doesn't get cleared while the Command is still working + List usersToDelete = widget.checked.toList(); + await DeleteContactCommand(context).execute(usersToDelete, onDeleteConfirmed: () { + // For a nicer UI interaction, we want to clear the list immediately when the User has confirmed their delete intent. + widget.checked.clear(); + widget.onCheckChanged?.call(); + }); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + TextStyle linkStyle = TextStyles.Body2.textHeight(1.1).textColor(theme.accent1); + return StyledContainer( + theme.bg1, + height: 48, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + HSpace(Insets.l), + StyledCheckbox(value: _getValue(), onChanged: _handleCheckChanged), + HSpace(Insets.m), + Text("Select", style: TextStyles.H2.textHeight(1)), + HSpace(Insets.sm * 1.5), + TransparentTextBtn("All", style: linkStyle, onPressed: () => _handleCheckChanged(true)), + Text(" / ", style: linkStyle).translate(offset: Offset(-Insets.sm, 0)), + TransparentTextBtn("None", style: linkStyle, onPressed: () => _handleCheckChanged(false)) + .translate(offset: Offset(-Insets.sm * 2, 0)), + HSpace(Insets.m), +//TODO: Implement ManageLabels btn +// TransparentIconAndTextBtn("Manage Labels", StyledIcons.label, style: linkStyle), +// HSpace(Insets.m), + TransparentIconAndTextBtn("Delete", StyledIcons.trash, + style: linkStyle.textColor(theme.grey), onPressed: _handleDeletePressed, color: theme.grey), + Spacer(), + Text("${widget.checked.length} Selected", style: TextStyles.H2.textColor(theme.grey)), + HSpace(Insets.l), + ], + ), + ); + } + + bool _getValue() { + if (widget.checked.isEmpty) return false; + if (widget.checked.length == widget.all.length) return true; + return null; + } +} diff --git a/flokk_src/lib/views/contact_page/contacts_list_row.dart b/flokk_src/lib/views/contact_page/contacts_list_row.dart new file mode 100644 index 0000000..0000dc8 --- /dev/null +++ b/flokk_src/lib/views/contact_page/contacts_list_row.dart @@ -0,0 +1,250 @@ +import 'package:flokk/_internal/components/mouse_hover_builder.dart'; +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/widget_view.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/contacts/toggle_favorite_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/social/clickable_social_badges.dart'; +import 'package:flokk/styled_components/styled_checkbox.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styled_components/styled_user_avatar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactsListRow extends StatefulWidget { + final ContactData contact; + final bool oddRow; + final bool lastNameFirst; + final double parentWidth; + final bool isSelected; + final bool isChecked; + final bool isStarred; + final bool showDividers; + final ShapeBorder shape; + + ContactsListRow( + this.contact, { + Key key, + this.oddRow = false, + this.lastNameFirst = false, + this.parentWidth, + this.isSelected = false, + this.isChecked = false, + this.isStarred = false, + this.shape, + this.showDividers = true, + }) : super(key: key); + + @override + _ContactsListRowState createState() => _ContactsListRowState(); +} + +class _ContactsListRowState extends State { + void handleStarPressed() { + ToggleFavoriteCommand(context).execute(widget.contact); + setState(() {}); + } + + @override + Widget build(BuildContext context) => ContactListCardView(this); +} + +class ContactListCardView extends WidgetView { + const ContactListCardView(_ContactsListRowState state, {Key key}) : super(state, key: key); + + ContactData get contact => widget.contact; + + bool get headerMode => contact == null; + + void _handleRowPressed(BuildContext context) { + context.read().trySetSelectedContact(widget.contact); + } + + void _handleRowChecked(BuildContext context, bool value) { + context.read().setCheckedContact(widget.contact, value); + } + + @override + Widget build(BuildContext context) { + var theme = context.watch(); + Color bgColor = headerMode ? Colors.transparent : theme.surface; + if (widget.isSelected) { + bgColor = theme.greyWeak.withOpacity(.35); + } + + double width = widget.parentWidth ?? context.widthPx; + int colCount = 1; + if (width > 450) colCount = 2; + if (width > 600) colCount = 3; + if (width > 1000) colCount = 4; + if (width > 1300) colCount = 5; + + TextStyle textStyle = !headerMode ? TextStyles.Body1.size(15) : TextStyles.H2.copyWith(color: theme.greyStrong); + Widget rowText(String value) => OneLineText(value, style: textStyle); + + Widget btn = BaseStyledBtn( + onPressed: headerMode ? null : () => _handleRowPressed(context), + bgColor: bgColor, + downColor: bgColor, + hoverColor: widget.isSelected ? bgColor : Colors.transparent, + shape: widget.shape ?? RoundedRectangleBorder(), + contentPadding: EdgeInsets.zero, + useBtnText: false, + child: Stack( + children: [ + /// DIVIDERS - Top and Bottom + if (!headerMode && widget.showDividers) Container(width: double.infinity, height: 1, color: theme.bg1), + if (headerMode) + Align( + alignment: Alignment.bottomLeft, + child: Container(width: double.infinity, height: 1, color: theme.grey.withOpacity(.6)), + ), + Row( + //mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + /// Name & ProfilePic + (headerMode + ? rowText("Name") + : _ProfileCheckboxWithLabels( + contact, + isChecked: state.widget.isChecked, + onChecked: (value) => _handleRowChecked(context, value), + )) + .constrained(minWidth: 300) + .expanded(flex: 20 * 100), + + /// Social Media + _FadingFlexContent( + isVisible: colCount > 1, + flex: 10, + child: headerMode ? rowText("Social") : ClickableSocialBadges(contact, showTimeSince: false), + ), + + /// Phone + _FadingFlexContent( + child: rowText(headerMode ? "Phone" : contact.firstPhone), + flex: 11, + isVisible: colCount > 2, + ), + + /// Email + _FadingFlexContent( + isVisible: colCount > 3, + flex: 16, + child: rowText(headerMode ? "Email" : contact.firstEmail), + ), + + /// COMPANY + _FadingFlexContent( + isVisible: colCount > 4, + flex: 16, + child: rowText(headerMode ? "Job / Company" : contact.jobCompany), + ), + + //SizedBox(width: Insets.m), + /// Star Icon + if (!headerMode) + TransparentBtn( + bigMode: true, + onPressed: state.handleStarPressed, + child: StyledImageIcon( + widget.contact.isStarred ? StyledIcons.starFilled : StyledIcons.starEmpty, + color: widget.contact.isStarred ? theme.accent1Dark : theme.greyWeak, + ).opacity(headerMode ? 0 : 1), + ) + ], + ).padding( + left: headerMode ? 0 : Insets.m, + right: Insets.m * 1.5, + vertical: Insets.sm, + ) + ], + ), + ); + return btn; + } +} + +class _ProfileCheckboxWithLabels extends StatefulWidget { + final Function(bool) onChecked; + final ContactData contact; + final bool isChecked; + + const _ProfileCheckboxWithLabels(this.contact, {Key key, this.onChecked, this.isChecked}) : super(key: key); + + @override + _ProfileCheckboxWithLabelsState createState() => _ProfileCheckboxWithLabelsState(); +} + +class _ProfileCheckboxWithLabelsState extends State<_ProfileCheckboxWithLabels> { + @override + Widget build(BuildContext context) { + double size = 42; + return Row( + children: [ + MouseHoverBuilder( + builder: (_, isHovering) { + bool showCheckbox = isHovering || widget.isChecked; + return IndexedStack( + index: showCheckbox ? 0 : 1, + children: [ + _buildCheckbox(size), + StyledUserAvatar(size: size, contact: widget.contact), + ], + ); + }, + ).gestures(onTapUp: (d) => widget.onChecked(!widget.isChecked), behavior: HitTestBehavior.opaque), + SizedBox(width: Insets.m), + OneLineText(widget.contact.nameFull, style: TextStyles.Body1.size(15)).expanded(), + ], + ); + } + + Widget _buildCheckbox(double size) { + return Container( + width: size, + height: size, + alignment: Alignment.center, + child: StyledCheckbox(size: 18, value: widget.isChecked), + ); + } +} + +class _FadingFlexContent extends StatelessWidget { + final Widget child; + final int flex; + final bool isVisible; + final bool enableAnimations; + + const _FadingFlexContent({Key key, this.child, this.flex, this.isVisible = true, this.enableAnimations = true}) + : super(key: key); + + @override + Widget build(BuildContext context) { + if (isVisible == false) return Container(); + int targetFlex = 1 + flex * 100; + if (enableAnimations) { + return TweenAnimationBuilder( + curve: !isVisible ? Curves.easeOut : Curves.easeIn, + tween: Tween(begin: isVisible ? 1 : 0, end: isVisible ? 1 : 0), + duration: (isVisible ? .5 : .2).seconds, + builder: (_, value, child) { + if (value == 0 && !isVisible) return Container(); + return child.opacity(value).expanded(flex: (targetFlex * value).round()); +// + }, + child: Container(child: child, alignment: Alignment.centerLeft)); + } + + return Container( + child: child, + alignment: Alignment.centerLeft, + ).expanded(flex: targetFlex); + } +} diff --git a/flokk_src/lib/views/contact_page/contacts_list_with_headers.dart b/flokk_src/lib/views/contact_page/contacts_list_with_headers.dart new file mode 100644 index 0000000..6de1882 --- /dev/null +++ b/flokk_src/lib/views/contact_page/contacts_list_with_headers.dart @@ -0,0 +1,133 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/styled_components/scrolling/styled_listview.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_page/bulk_contact_edit_bar.dart'; +import 'package:flokk/views/contact_page/contacts_list_row.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class ContactsListWithHeaders extends StatefulWidget { + final List contacts; + final List checkedContacts; + final ContactData selectedContact; + final ContactOrderBy orderBy; + final bool orderDesc; + final bool searchMode; + final bool showHeaders; + + const ContactsListWithHeaders({ + Key key, + this.contacts, + this.orderBy, + this.orderDesc, + this.showHeaders, + this.checkedContacts, + this.selectedContact, + this.searchMode, + }) : super(key: key); + + @override + _ContactsListWithHeadersState createState() => _ContactsListWithHeadersState(); +} + +class _ContactsListWithHeadersState extends State { + bool orderDesc = false; + + List get checked => widget.checkedContacts; + + bool _getIsChecked(String id) { + if (id == null) return false; + ContactData c = widget.checkedContacts?.firstWhere((_c) => _c.id == id, orElse: () => null); + return c != null; + } + + Tuple2, int> getSortedContactsWithFavoriteCount() { + List starred = widget.contacts.toList()..removeWhere((element) => !element.isStarred); + int starCount = starred.length; + List nonStarred = widget.contacts.toList()..removeWhere((element) => element.isStarred); + return Tuple2(starred..addAll({...nonStarred}), starCount); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return LayoutBuilder( + builder: (_, constraints) { + Tuple2, int> contactsWithFavCount = getSortedContactsWithFavoriteCount(); + List contacts = contactsWithFavCount.item1; + int favCount = contactsWithFavCount.item2; + + return Stack( + children: [ + /// //////////////////////////////////////// + /// LIST / HEADER COLUMN + Column( + children: [ + /// Header: Pass a null contact, the renderer will switch to header mode + ContactsListRow(null, parentWidth: constraints.maxWidth) + .constrained(height: 48) + .padding(right: Insets.lGutter - Insets.sm), + + /// List + StyledListView( + itemExtent: 78, + itemCount: widget.contacts.length + (favCount == 0 || favCount == widget.contacts.length ? 1 : 2), + itemBuilder: (context, i) { + /// Inject 1 or 2 header rows into the results + bool isFirstHeader = i == 0; + bool isSecondHeader = i == favCount + 1 && favCount != 0; + if (isFirstHeader || (isSecondHeader && !widget.searchMode)) { + String headerText = "SEARCH RESULTS"; + int count = contacts.length; + if (!widget.searchMode) { + bool isFavorite = i == 0 && favCount > 0; + headerText = isFavorite ? "FAVORITE CONTACTS" : "OTHER CONTACTS"; + count = isFavorite ? favCount : contacts.length - favCount; + } + /// Header text + return Container( + child: Text("$headerText ($count)", style: TextStyles.T1.textColor(theme.accent1Dark)), + alignment: Alignment.bottomLeft, + margin: EdgeInsets.only(bottom: Insets.l + 4), + ); + } + // Regular Row + else { + //Because the list is 2 items longer + int offset = i <= favCount || favCount == 0 ? 1 : 2; + ContactData c = contacts[i - offset]; + return ContactsListRow( + c, + key: ValueKey(c.id), + // Pass our width into the renderers as an optimization, so they don't need to calculate their own + parentWidth: constraints.maxWidth, + isSelected: c.id == widget.selectedContact?.id, + isChecked: _getIsChecked(c.id), + ); + } + }, + ).expanded(), + ], + ), + + /// //////////////////////////////////////// + /// BULK CONTROLS + BulkContactEditBar( + checked: checked, + all: context.read().allContacts, + onCheckChanged: () => setState(() {}), + ) + .opacity(checked.isEmpty ? 0 : 1, animate: true) + .scale(checked.isEmpty ? .98 : 1, animate: true) + .translate(offset: Offset(0, checked.isEmpty ? -4 : 0), animate: true) + .animate(.1.seconds, Curves.easeOut) + ], + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/contact_page/contacts_page.dart b/flokk_src/lib/views/contact_page/contacts_page.dart new file mode 100644 index 0000000..b552d5d --- /dev/null +++ b/flokk_src/lib/views/contact_page/contacts_page.dart @@ -0,0 +1,78 @@ +import 'package:flokk/_internal/components/listenable_builder.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/widget_view.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/contact_page/contacts_list_with_headers.dart'; +import 'package:flokk/views/empty_states/placeholder_contact_list.dart'; +import 'package:flokk/views/empty_states/placeholder_content_switcher.dart'; +import 'package:flokk/views/search/search_engine.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactsPage extends StatefulWidget { + final SearchEngine searchEngine; + final List checkedContacts; + final ContactData selectedContact; + + const ContactsPage({Key key, this.searchEngine, this.checkedContacts, this.selectedContact}) : super(key: key); + + @override + ContactsPageState createState() => ContactsPageState(); +} + +class ContactsPageState extends State { + Future handleRefreshTriggered() async => await RefreshContactsCommand(context).execute(); + + @override + Widget build(BuildContext context) => _ContactsPageView(this); +} + +class _ContactsPageView extends WidgetView { + _ContactsPageView(ContactsPageState state) : super(state); + + @override + Widget build(BuildContext context) { + context.watch(); + return Stack( + alignment: Alignment.center, + children: [ + Column(children: [ + VSpace(Insets.sm), + + /// Bind to SearchEngine + ListenableBuilder( + listenable: widget.searchEngine, + builder: (_, __) { + /// Filter active contacts using the search engine provided + List sorted = widget.searchEngine.getResults(); + + /// Wrap content in PlaceholderSwitcher, which will handle empty results + return PlaceholderContentSwitcher( + hasContent: () => sorted.isNotEmpty, + placeholder: ContactListPlaceholder(isSearching: widget.searchEngine.hasQuery), + showOutline: false, + placeholderPadding: EdgeInsets.only(top: Insets.m * 1.5, right: Insets.m, bottom: Insets.m), + + /// ContactList + content: ContactsListWithHeaders( + contacts: sorted, + // Indicate to page that it should show search-results formatting + searchMode: widget.searchEngine.hasQuery, + selectedContact: widget.selectedContact, + checkedContacts: widget.checkedContacts, + orderBy: widget.searchEngine.orderBy, + orderDesc: widget.searchEngine.orderDesc, + ), + ); + }, + ).expanded(), + VSpace(Insets.m), + ]), + ], + ); + } +} diff --git a/flokk_src/lib/views/dashboard_page/dashboard_page.dart b/flokk_src/lib/views/dashboard_page/dashboard_page.dart new file mode 100644 index 0000000..19b79ea --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/dashboard_page.dart @@ -0,0 +1,39 @@ +import 'package:flokk/_internal/components/scrolling_flex_view.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/dashboard_page/dates/important_dates_section.dart'; +import 'package:flokk/views/dashboard_page/social/social_activities_section.dart'; +import 'package:flokk/views/dashboard_page/top/top_contacts_section.dart'; +import 'package:flutter/material.dart'; + +class DashboardPage extends StatefulWidget { + final ContactData selectedContact; + + const DashboardPage({Key key, this.selectedContact}) : super(key: key); + + @override + DashboardPageState createState() => DashboardPageState(); +} + +class DashboardPageState extends State { + @override + Widget build(BuildContext context) { + return ConstrainedFlexView(850, + scrollPadding: EdgeInsets.only(right: Insets.m), + child: Column( + children: [ + SizedBox(height: Insets.l), + TopContactsSection(), + VSpace(Insets.m), + SocialActivitySection().padding(horizontal: Insets.lGutter).flexible(), + SizedBox(height: Insets.l * 1.5), + RepaintBoundary( + child: UpcomingActivitiesSection().height(170).padding(horizontal: Insets.lGutter), + ), + SizedBox(height: Insets.l), + ], + )); + } +} diff --git a/flokk_src/lib/views/dashboard_page/dates/important_date_card.dart b/flokk_src/lib/views/dashboard_page/dates/important_date_card.dart new file mode 100644 index 0000000..b306745 --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/dates/important_date_card.dart @@ -0,0 +1,63 @@ +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/styled_card.dart'; +import 'package:flokk/styled_components/styled_user_avatar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +class ImportantEventCard extends StatelessWidget { + const ImportantEventCard(this.contact, this.event, {Key key}) : super(key: key); + + static DateFormat get monthDayFmt => DateFormat("MMMMEEEEd"); + + final ContactData contact; + final DateMixin event; + + bool get isBirthday => event is BirthdayData; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + TextStyle cardContentText = TextStyles.H2.textColor(theme.txt); + Color eventColor = isBirthday ? theme.accent3 : theme.accent2; + + return TransparentBtn( + onPressed: () => context.read().trySetSelectedContact(contact, showSocial: false), + borderRadius: Corners.s8, + contentPadding: EdgeInsets.zero, + child: StyledCard( + align: Alignment.center, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + HSpace(Insets.m), + StyledUserAvatar(contact: contact, size: 40), + HSpace(Insets.sm), + Text(contact.nameFull, style: cardContentText), + Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Spacer(), + OneLineText(event.getType(), style: cardContentText.textColor(eventColor)), + VSpace(Insets.sm), + OneLineText(monthDayFmt.format(event.date), style: cardContentText), + Spacer(), + ], + ).width(110), + HSpace(Insets.m), + //Text(cWithD.item2.date.toString()), + ], + ).padding(all: Insets.sm), + ).height(54), + ); + } +} diff --git a/flokk_src/lib/views/dashboard_page/dates/important_dates_section.dart b/flokk_src/lib/views/dashboard_page/dates/important_dates_section.dart new file mode 100644 index 0000000..217ebbb --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/dates/important_dates_section.dart @@ -0,0 +1,56 @@ +import 'dart:math'; + +import 'package:flokk/_internal/components/simple_grid.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/dashboard_page/dates/important_date_card.dart'; +import 'package:flokk/views/empty_states/placeholder_content_switcher.dart'; +import 'package:flokk/views/empty_states/placeholder_important_dates.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; +import 'package:tuple/tuple.dart'; + +class UpcomingActivitiesSection extends StatelessWidget { + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + ContactsModel contactsModel = context.watch(); + List> contactsWithDate = contactsModel.upcomingDateContacts; + TextStyle headerStyle = TextStyles.T1; + + /// Create list of ItemRenderers + List kids = contactsWithDate.map((cWithD) => ImportantEventCard(cWithD.item1, cWithD.item2)).toList(); + + /// Build list + return LayoutBuilder( + builder: (_, constraints) { + int colCount = max(1, (constraints.maxWidth / 320).floor()); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("UPCOMING IMPORTANT DATES", style: headerStyle.textColor(theme.accent1Darker)), + VSpace(Insets.l * .75), + PlaceholderContentSwitcher( + hasContent: () => kids.isNotEmpty, + placeholder: ImportantDatesPlaceholder(), + content: SingleChildScrollView( + child: SimpleGrid( + kidHeight: 54, + kids: kids, + colCount: colCount, + hSpace: Insets.m * 1.5, + vSpace: Insets.l * .75, + ), + ), + ).flexible(), + ], + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/dashboard_page/social/responsive_double_list.dart b/flokk_src/lib/views/dashboard_page/social/responsive_double_list.dart new file mode 100644 index 0000000..b035148 --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/social/responsive_double_list.dart @@ -0,0 +1,83 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/scrolling/styled_listview.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/dashboard_page/social/tabbed_list_view.dart'; +import 'package:flokk/views/empty_states/placeholder_content_switcher.dart'; +import 'package:flutter/material.dart'; + +/// This Widget displays 2 lists in either a dual-column mode or a combined tabbed-view +class ResponsiveDoubleList extends StatefulWidget { + final bool useTabView; + + final List list1; + final String list1Title; + + final List list2; + final String list2Title; + + final Widget list1Placeholder; + final Widget list2Placeholder; + + final AssetImage list1Icon; + final AssetImage list2Icon; + + const ResponsiveDoubleList( + {Key key, + @required this.list1, + @required this.list2, + @required this.list1Title = "", + @required this.list2Title = "", + @required this.list1Placeholder, + @required this.list2Placeholder, + @required this.useTabView, + @required this.list1Icon, + @required this.list2Icon}) + : super(key: key); + + @override + _ResponsiveDoubleListState createState() => _ResponsiveDoubleListState(); +} + +class _ResponsiveDoubleListState extends State { + int _selectedTabIndex = 0; + + void _handleSocialTabPressed(int index) { + print(index); + setState(() => _selectedTabIndex = index); + } + + @override + Widget build(BuildContext context) { + if (widget.useTabView) { + return TabbedListView( + index: _selectedTabIndex, + onTabPressed: _handleSocialTabPressed, + list1: widget.list1, + list1Placeholder: widget.list1Placeholder, + list1Title: widget.list1Title, + list1Icon: widget.list1Icon, + list2: widget.list2, + list2Placeholder: widget.list2Placeholder, + list2Title: widget.list2Title, + list2Icon: widget.list2Icon, + ); + } else { + return Row( + children: [ + PlaceholderContentSwitcher( + hasContent: () => widget.list1.isNotEmpty, + placeholder: widget.list1Placeholder, + content: StyledListViewWithTitle(listItems: widget.list1, title: widget.list1Title, icon: widget.list1Icon), + ).flexible(), + HSpace(Insets.l), + PlaceholderContentSwitcher( + hasContent: () => widget.list2.isNotEmpty, + placeholder: widget.list2Placeholder, + content: StyledListViewWithTitle(listItems: widget.list2, title: widget.list2Title, icon: widget.list2Icon), + ).flexible(), + ], + ); + } + } +} diff --git a/flokk_src/lib/views/dashboard_page/social/social_activities_section.dart b/flokk_src/lib/views/dashboard_page/social/social_activities_section.dart new file mode 100644 index 0000000..ae27b19 --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/social/social_activities_section.dart @@ -0,0 +1,139 @@ +import 'package:flokk/_internal/components/fading_index_stack.dart'; +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/models/github_model.dart'; +import 'package:flokk/models/twitter_model.dart'; +import 'package:flokk/styled_components/social/git_item_renderer.dart'; +import 'package:flokk/styled_components/social/tweet_item_renderer.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_tab_bar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/dashboard_page/social/responsive_double_list.dart'; +import 'package:flokk/views/empty_states/placeholder_git.dart'; +import 'package:flokk/views/empty_states/placeholder_twitter.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SocialActivitySection extends StatefulWidget { + @override + _SocialActivitySectionState createState() => _SocialActivitySectionState(); +} + +class _SocialActivitySectionState extends State { + void _handleTabPressed(int index) { + if (index == 0) context.read().dashSocialSection = DashboardSocialSectionType.All; + if (index == 1) context.read().dashSocialSection = DashboardSocialSectionType.Twitter; + if (index == 2) context.read().dashSocialSection = DashboardSocialSectionType.Git; + context.read().scheduleSave(); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + GithubModel gitModel = context.watch(); + TwitterModel twitterModel = context.watch(); + return LayoutBuilder( + builder: (_, constraints) { + /// Responsively size tab bars + double tabWidth = constraints.maxWidth < PageBreaks.LargePhone ? 240 : 280; + TextStyle headerStyle = TextStyles.T1; + + bool useTabView = constraints.maxWidth < PageBreaks.TabletPortrait - 100; + + /// Determine which tab should be selected + var sectionType = context.select((model) => model.dashSocialSection); + int tabIndex = 0; + if (sectionType == DashboardSocialSectionType.Twitter) tabIndex = 1; + if (sectionType == DashboardSocialSectionType.Git) tabIndex = 2; + + /// Get the 2 lists that should be displayed + int maxItems = 20; + List list1; + String list1Title = ""; + List list2; + String list2Title = ""; + Widget list1Placeholder; + Widget list2Placeholder; + AssetImage icon1; + AssetImage icon2; + + // ALL + if (sectionType == DashboardSocialSectionType.All) { + list1Title = "TWITTER RECENT ACTIVITY"; + list1 = twitterModel.allTweets.map((tweet) => TweetListItem(tweet)).take(maxItems).toList(); + list1Placeholder = TwitterPlaceholder(); + icon1 = StyledIcons.twitterActive; + list2Title = "GITHUB RECENT ACTIVITY"; + list2 = gitModel.allEvents.map((event) => GitEventListItem(event)).take(maxItems).toList(); + list2Placeholder = GitPlaceholder(); + icon2 = StyledIcons.githubActive; + } + // GITHUB + else if (sectionType == DashboardSocialSectionType.Git) { + list1Title = "GITHUB RECENT ACTIVITY"; + list1Placeholder = GitPlaceholder(); + list1 = gitModel.allEvents.map((event) => GitEventListItem(event)).take(maxItems).toList(); + icon1 = StyledIcons.githubActive; + list2Title = "TRENDING REPOSITORIES"; + list2Placeholder = GitPlaceholder(isTrending: true); + list2 = gitModel.popularRepos.map((repo) => GitRepoListItem(repo)).take(maxItems).toList(); + icon2 = StyledIcons.githubActive; + } + // TWITTER + else if (sectionType == DashboardSocialSectionType.Twitter) { + list1 = twitterModel.allTweets.map((e) => TweetListItem(e)).take(maxItems).toList(); + list1Placeholder = TwitterPlaceholder(); + list1Title = "TWITTER RECENT ACTIVITY"; + icon1 = StyledIcons.twitterActive; + list2 = twitterModel.popularTweets.map((e) => TweetListItem(e)).take(maxItems).toList(); + list2Placeholder = TwitterPlaceholder(isPopular: true); + list2Title = "POPULAR TWEETS"; + icon2 = StyledIcons.twitterActive; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OneLineText("SOCIAL ACTIVITIES", style: headerStyle.textColor(theme.accent1Darker)).flexible(), + StyledTabBar( + index: tabIndex, + sections: ["All", "Twitter", "GitHub"], + onTabPressed: _handleTabPressed, + ).constrained(maxWidth: tabWidth, animate: true).animate(Durations.medium, Curves.easeOut), + ], + ), + VSpace(Insets.l * .75), + FadingIndexedStack( + index: tabIndex, + duration: Durations.fastest, + children: [ + /// This looks weird, but it's really pretty robust / elegant + /// Create 3 children, only the child that matches tabIndex will get the latest data, the previous index will fadeout while retaining it's old state. + /// Doing it this way preserves scroll position & state for all tabs + ...List.generate(3, (index) { + return ResponsiveDoubleList( + list1: list1, + list1Title: list1Title, + list2: list2, + list2Title: list2Title, + list1Placeholder: list1Placeholder, + list2Placeholder: list2Placeholder, + useTabView: useTabView, + list1Icon: icon1, + list2Icon: icon2, + ); + }), + ], + ).expanded(), + ], + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/dashboard_page/social/tabbed_list_view.dart b/flokk_src/lib/views/dashboard_page/social/tabbed_list_view.dart new file mode 100644 index 0000000..b5aa56d --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/social/tabbed_list_view.dart @@ -0,0 +1,194 @@ +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/social_activity_type.dart'; +import 'package:flokk/styled_components/buttons/base_styled_button.dart'; +import 'package:flokk/styled_components/scrolling/styled_listview.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/empty_states/placeholder_content_switcher.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TabbedListView extends StatelessWidget { + final List list1; + final String list1Title; + + final List list2; + final String list2Title; + + final Widget list1Placeholder; + final Widget list2Placeholder; + final AssetImage list1Icon; + final AssetImage list2Icon; + + final void Function(int) onTabPressed; + + final int index; + + const TabbedListView( + {Key key, + @required this.list1, + @required this.list1Title, + @required this.list2, + @required this.list2Title, + this.list1Placeholder, + this.list2Placeholder, + this.index = 0, + this.onTabPressed, this.list1Icon, this.list2Icon}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + double barHeight = 40; + + ShapeDecoration buildTabDec(bool isBg) { + return ShapeDecoration( + shape: TabBorder(selectedTab: isBg ? null : index, barHeight: barHeight), + color: isBg ? theme.surface : ColorUtils.blend(theme.bg1, theme.bg2, .35), + shadows: isBg ? Shadows.m(theme.accent1) : null, + ); + } + + bool firstSelected = index == 0; + + return Stack( + children: [ + /// Tab Bg + Container(decoration: buildTabDec(true), foregroundDecoration: buildTabDec(false)), + + /// Top Row of Btns + Row( + children: [ + Container( + child: _TransparentTabBtn( + title: list1Title, + icon: list1Icon, + isSelected: firstSelected, + height: barHeight, + type: SocialActivityType.Git, + onPressed: () => onTabPressed?.call(0), + ), + ).expanded(), + Container( + child: _TransparentTabBtn( + title: list2Title, + icon: list2Icon, + isSelected: !firstSelected, + height: barHeight, + type: SocialActivityType.Twitter, + onPressed: () => onTabPressed?.call(1), + ), + ).expanded(), + ], + ).height(barHeight), + + /// Content + Container( + child: Container( + margin: EdgeInsets.all(Insets.l).copyWith(right: Insets.m, top: Insets.m * 1.5), + child: PlaceholderContentSwitcher( + hasContent: () => firstSelected ? list1.isNotEmpty : list2.isNotEmpty, + placeholderPadding: EdgeInsets.only(right: Insets.m), + placeholder: (firstSelected ? list1Placeholder : list2Placeholder) ?? Container(), + content: StyledListView( + itemCount: firstSelected ? list1.length : list2.length, + itemBuilder: (_, i) => firstSelected ? list1[i] : list2[i], + ), + ), + ), + ).positioned(top: barHeight, bottom: 0, left: 0, right: 0), + ], + ); + } +} + +class _TransparentTabBtn extends StatelessWidget { + final bool isSelected; + final SocialActivityType type; + final void Function() onPressed; + final double height; + final String title; + final AssetImage icon; + + const _TransparentTabBtn({Key key, this.isSelected = false, this.type, this.onPressed, this.height, this.title, this.icon}) + : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + Color color = isSelected ? theme.accent1Darker : theme.grey; + TextStyle titleStyle = TextStyles.T2.textColor(color); + return BaseStyledBtn( + contentPadding: EdgeInsets.zero, + bgColor: Colors.transparent, + downColor: Colors.transparent, + hoverColor: Colors.transparent, + onPressed: isSelected ? null : onPressed, + child: Row( + children: [ + HSpace(Insets.m * 1.5), + StyledImageIcon(icon, color: color, size: 26), + HSpace(Insets.sm), + OneLineText(title, style: titleStyle).flexible(), + ], + ), + ).height(height); + } +} + +class TabBorder extends ShapeBorder { + final int selectedTab; + final double barHeight; + + TabBorder({this.selectedTab, this.barHeight}); + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + Path getInnerPath(Rect rect, {TextDirection textDirection}) => null; + + @override + Path getOuterPath(Rect rect, {TextDirection textDirection}) { + var radius = Radius.circular(8); + + void drawBody(Path p) { + Rect tabRect = Rect.fromLTWH(rect.left, rect.top + barHeight, rect.width, rect.height - barHeight); + p.addRRect(RRect.fromRectAndCorners(tabRect, + topLeft: Radius.zero, bottomLeft: radius, topRight: Radius.zero, bottomRight: radius)); + } + + void drawTab(Path p, bool rightSide) { + double xPos = rightSide ? rect.width * .5 : 0; + Rect tabRect = Rect.fromLTWH(rect.left + xPos, rect.top, rect.width * .5, barHeight); + p.addRRect(RRect.fromRectAndCorners(tabRect, + topLeft: radius, bottomLeft: Radius.zero, topRight: radius, bottomRight: Radius.zero)); + } + + //Bg mode draws 2 tabs and a body section. Otherwise, just the un-selected tab is drawn. + bool bgMode = selectedTab == null; + Path path = Path(); + //Draw Left side? + if (bgMode || selectedTab == 1) { + drawTab(path, false); + } + //Draw Right Side? + if (bgMode || selectedTab == 0) { + drawTab(path, true); + } + if (bgMode) { + drawBody(path); + } + return path; + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {} + + @override + ShapeBorder scale(double t) => this; +} diff --git a/flokk_src/lib/views/dashboard_page/top/small_contact_card.dart b/flokk_src/lib/views/dashboard_page/top/small_contact_card.dart new file mode 100644 index 0000000..08efd0d --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/top/small_contact_card.dart @@ -0,0 +1,55 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/social/clickable_social_badges.dart'; +import 'package:flokk/styled_components/styled_card.dart'; +import 'package:flokk/styled_components/styled_user_avatar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SmallContactCard extends StatelessWidget { + + static const double cardWidth = 162; + + final ContactData contact; + + const SmallContactCard(this.contact, {Key key}) : super(key: key); + + void _handleCardPressed(BuildContext c) => + c.read().trySetSelectedContact(contact, showSocial: false); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + Color txtColor = theme.isDark ? Colors.white : Colors.black; + return StyledCard( + onPressed: () => _handleCardPressed(context), + child: Container( + margin: EdgeInsets.all(Insets.m), + width: cardWidth - Insets.m * 2, + child: Column( + children: [ + StyledUserAvatar(contact: contact, size: 60), + VSpace(Insets.m), + Text( + contact?.nameFull ?? "", + maxLines: 2, + overflow: TextOverflow.fade, + style: TextStyles.H2.textHeight(1.3).textColor(txtColor).regular, + textAlign: TextAlign.center, + ).center().height(30), + Spacer(), + ClickableSocialBadges(contact, showTimeSince: true), + ], + ), + ), + ).padding( + right: Insets.m * 1.75, + vertical: Insets.m, + ); + } +} diff --git a/flokk_src/lib/views/dashboard_page/top/top_contacts_section.dart b/flokk_src/lib/views/dashboard_page/top/top_contacts_section.dart new file mode 100644 index 0000000..7068dbe --- /dev/null +++ b/flokk_src/lib/views/dashboard_page/top/top_contacts_section.dart @@ -0,0 +1,117 @@ +import 'package:flokk/_internal/components/fading_index_stack.dart'; +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/styled_components/scrolling/styled_listview.dart'; +import 'package:flokk/styled_components/styled_tab_bar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/dashboard_page/top/small_contact_card.dart'; +import 'package:flokk/views/empty_states/placeholder_content_switcher.dart'; +import 'package:flokk/views/empty_states/placeholder_top_contacts.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class TopContactsSection extends StatefulWidget { + const TopContactsSection({Key key}) : super(key: key); + + @override + _TopContactsSectionState createState() => _TopContactsSectionState(); +} + +class _TopContactsSectionState extends State { + void _handleTabPressed(int index) { + context.read().dashContactsSection = + index == 0 ? DashboardContactsSectionType.Favorites : DashboardContactsSectionType.RecentlyActive; + context.read().scheduleSave(); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + ContactsModel contactsModel = context.watch(); + + /// Bind to section type on AppModel + var sectionType = context.select((model) => model.dashContactsSection); + int tabIndex = 0; + if (sectionType == DashboardContactsSectionType.RecentlyActive) { + tabIndex = 1; + } + + /// Use a layout builder so we can size this view responsively when the panel slides out + return LayoutBuilder( + builder: (_, constraints) { + List contacts = sectionType == DashboardContactsSectionType.Favorites + ? contactsModel.starred + : contactsModel.mostRecentSocialContacts.map((e) => e.contact).toList(); + double tabWidth = constraints.maxWidth < PageBreaks.LargePhone ? 240 : 280; + TextStyle headerStyle = TextStyles.T1; + double cardHeight = 208; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OneLineText("CONTACTS", style: headerStyle.textColor(theme.accent1Darker)).flexible(), + StyledTabBar( + index: tabIndex, + sections: ["Favorites", "Recently Active"], + onTabPressed: _handleTabPressed, + ).constrained(maxWidth: tabWidth, animate: true).animate(Durations.medium, Curves.easeOut), + ], + ).padding(horizontal: Insets.lGutter), + VSpace(Insets.sm), + + /// Fading Stack to hold the 2 lists + FadingIndexedStack( + index: tabIndex, + children: [ + _ContactCardList(this, contacts: contacts, placeholder: TopContactsPlaceholder()), + _ContactCardList(this, contacts: contacts, placeholder: TopContactsPlaceholder(isRecent: true)), + ], + ).height(cardHeight + Insets.m * 2), + ], + ); + }, + ); + } +} + +class _ContactCardList extends StatelessWidget { + final _TopContactsSectionState state; + final List contacts; + final Widget placeholder; + + const _ContactCardList(this.state, {Key key, this.contacts, this.placeholder}) : super(key: key); + + @override + Widget build(BuildContext context) { + /// Create list of item renderers + List contactCards = contacts.map((c) => SmallContactCard(c)).toList(); + + /// Layout content + EdgeInsets padding = EdgeInsets.symmetric(horizontal: Insets.l, vertical: Insets.m); + // Placeholder content-box + return PlaceholderContentSwitcher( + hasContent: () => contacts.isNotEmpty, + placeholder: placeholder, + placeholderPadding: padding, + content: StyledListView( + axis: Axis.horizontal, + itemCount: contacts.length, + itemExtent: SmallContactCard.cardWidth, + padding: EdgeInsets.only(left: Insets.l), + scrollbarPadding: EdgeInsets.only(left: Insets.m, right: Insets.sm), + barSize: 6, + itemBuilder: (_, index) => SmallContactCard(contacts[index]), + //itemExtent: itemSize, + )); + } +} diff --git a/flokk_src/lib/views/empty_states/placeholder_contact_list.dart b/flokk_src/lib/views/empty_states/placeholder_contact_list.dart new file mode 100644 index 0000000..edfe7ce --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_contact_list.dart @@ -0,0 +1,37 @@ + +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/empty_states/placeholder_widget_helpers.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class ContactListPlaceholder extends StatelessWidget { + final bool isSearching; + + const ContactListPlaceholder({Key key, this.isSearching = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + var bgImage = Image.asset("assets/images/empty-noresult-bg@2x.png", height: 108, color: theme.bg2) + .translate(offset: Offset(8, 2)); + return Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + PlaceholderImageAndBgStack("noresult-owl", bgWidget: bgImage, height: 126, top: 15, left: -30), + VSpace(Insets.l), + isSearching + ? EmptyStateTitleAndClickableText( + title: "NO RESULTS IN YOUR CONTACTS", + startText: "We couldn't find any results that matched your search.\nPlease try another search.", + ) + : EmptyStateTitleAndClickableText( + onPressed: () => showContactPage(context), + title: "NO CONTACTS YET", + startText: "", + linkText: "Create Contacts", + endText: " to get started!", + ) + ]); + } +} \ No newline at end of file diff --git a/flokk_src/lib/views/empty_states/placeholder_content_switcher.dart b/flokk_src/lib/views/empty_states/placeholder_content_switcher.dart new file mode 100644 index 0000000..ac5774c --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_content_switcher.dart @@ -0,0 +1,52 @@ +import 'package:dotted_border/dotted_border.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +export 'package:flokk/views/empty_states/placeholder_widget_helpers.dart'; + +typedef bool HasContentCallback(); + +/// [PlaceholderContentSwitcher] Takes content and a placeholder, and swaps between them depending on the results of the hasContent delegate. +class PlaceholderContentSwitcher extends StatelessWidget { + final bool showOutline; + final Widget placeholder; + final Widget content; + final HasContentCallback hasContent; + final EdgeInsets placeholderPadding; + + const PlaceholderContentSwitcher( + {Key key, + this.showOutline = true, + @required this.placeholder, + @required this.content, + @required this.hasContent, + this.placeholderPadding = EdgeInsets.zero}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + hasContent()? content : _buildPlaceholder(context), + ], + ); + } + + _buildPlaceholder(BuildContext context) { + AppTheme theme = context.watch(); + return Container( + alignment: Alignment.center, + margin: placeholderPadding, + child: showOutline + ? DottedBorder( + dashPattern: [2, 4], + color: theme.greyWeak.withOpacity(.7), + borderType: BorderType.RRect, + radius: Corners.s8Radius, + child: Center(child: placeholder)) + : placeholder); + } +} diff --git a/flokk_src/lib/views/empty_states/placeholder_git.dart b/flokk_src/lib/views/empty_states/placeholder_git.dart new file mode 100644 index 0000000..5be0a9c --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_git.dart @@ -0,0 +1,41 @@ + +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/empty_states/placeholder_widget_helpers.dart'; +import 'package:flutter/material.dart'; + +class GitPlaceholder extends StatelessWidget { + final bool isTrending; + + // If contact is set, this widget will act as if it belongs to a single contact + final ContactData contact; + + const GitPlaceholder({Key key, this.isTrending = false, this.contact}) : super(key: key); + + void _handleLinkPressed(BuildContext context) { + //If in single-contact mode, try and edit the selected contact + if (contact != null) { + showSocial(context, ContactSectionType.github); + } + // Try and move to ContactList page + else { + showContactPage(context); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) => Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + if (constraints.maxHeight > 250) PlaceholderImageAndBgStack("dashboard-github", height: 126, top: 43, left: -30), + EmptyStateTitleAndClickableText( + title: isTrending ? "NO TRENDING REPOS" : "NO GITHUB ACTIVITY", + startText: contact == null ? "Add GitHub ID in " : "Add ", + linkText: contact == null ? "contacts" : "GitHub ID", + endText: " to show ${isTrending ? "trending repos" : "recent activity"}", + onPressed: () => _handleLinkPressed(context), + ), + ]), + ); + } +} diff --git a/flokk_src/lib/views/empty_states/placeholder_important_dates.dart b/flokk_src/lib/views/empty_states/placeholder_important_dates.dart new file mode 100644 index 0000000..3d6f47b --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_important_dates.dart @@ -0,0 +1,19 @@ +import 'package:flokk/views/empty_states/placeholder_widget_helpers.dart'; +import 'package:flutter/material.dart'; + +class ImportantDatesPlaceholder extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + EmptyStateTitleAndClickableText( + title: "NO UPCOMING IMPORTANT DATES", + startText: "Add birthdays/special dates to your ", + linkText: "contacts", + onPressed: ()=>showContactPage(context), + ), + ], + ); + } +} diff --git a/flokk_src/lib/views/empty_states/placeholder_top_contacts.dart b/flokk_src/lib/views/empty_states/placeholder_top_contacts.dart new file mode 100644 index 0000000..17a4dd0 --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_top_contacts.dart @@ -0,0 +1,43 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/empty_states/placeholder_widget_helpers.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class TopContactsPlaceholder extends StatelessWidget { + final bool isRecent; + + const TopContactsPlaceholder({Key key, this.isRecent = false}) : super(key: key); + + + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) { + bool showImage = constraints.maxWidth > 500; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showImage) ...{ + isRecent + ? PlaceholderImageAndBgStack("dashboard-recentActive", height: 157, top: 2) + : PlaceholderImageAndBgStack("dashboard-favorites", height: 126, top: 22), + HSpace(Insets.xl), + }, + EmptyStateTitleAndClickableText( + title: isRecent ? "NO RECENT ACTIVITY" : "NO FAVORITE CONTACTS", + startText: "${isRecent ? "Add GitHub and Twitter handles in " : "Star "}", + linkText: "contacts", + endText: " to show their recent activity", + onPressed: () => showContactPage(context), + crossAxisAlign: showImage ? CrossAxisAlignment.start : CrossAxisAlignment.center, + ).width(230).translate(offset: Offset(0, -15)) + ], + ); + }, + ); + } +} diff --git a/flokk_src/lib/views/empty_states/placeholder_twitter.dart b/flokk_src/lib/views/empty_states/placeholder_twitter.dart new file mode 100644 index 0000000..f1a56a6 --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_twitter.dart @@ -0,0 +1,41 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/empty_states/placeholder_widget_helpers.dart'; +import 'package:flutter/material.dart'; + +class TwitterPlaceholder extends StatelessWidget { + // If popular, this widget will use slightly different text + final bool isPopular; + + // If contact is set, this widget will act as if it belongs to a single contact + final ContactData contact; + + const TwitterPlaceholder({Key key, this.isPopular = false, this.contact}) : super(key: key); + + void _handleLinkPressed(BuildContext context) { + //If in single-contact mode, try and edit the selected contact + if (contact != null) { + showSocial(context, ContactSectionType.github); + } + // Try and move to ContactList page + else { + showContactPage(context); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (_, constraints) => Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + if (constraints.maxHeight > 250) PlaceholderImageAndBgStack("dashboard-twitter", height: 126, top: 43), + EmptyStateTitleAndClickableText( + title: isPopular ? "NO POPULAR TWEETS" : "NO TWITTER ACTIVITY", + startText: contact == null ? "Add Twitter Handles in " : "Add ", + linkText: contact == null ? "contacts" : "Twitter Handle", + endText: " to show ${isPopular ? "popular tweets" : "recent activity"}", + onPressed: () => _handleLinkPressed(context), + ), + ]), + ); + } +} diff --git a/flokk_src/lib/views/empty_states/placeholder_widget_helpers.dart b/flokk_src/lib/views/empty_states/placeholder_widget_helpers.dart new file mode 100644 index 0000000..0f437e2 --- /dev/null +++ b/flokk_src/lib/views/empty_states/placeholder_widget_helpers.dart @@ -0,0 +1,103 @@ + +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:styled_widget/styled_widget.dart'; + +void showSocial(BuildContext context, String type) { + context.read().editSelectedContact(type); +} + +void showContactPage(BuildContext context) { + context.read().trySetCurrentPage(PageType.ContactsList); +} + +/// ////////////////////////////////////////////////// +/// INTERNAL HELPER WIDGETS +class EmptyStateTitleAndClickableText extends StatelessWidget { + final String title; + final String startText; + final String endText; + final String linkText; + final CrossAxisAlignment crossAxisAlign; + final Function() onPressed; + + const EmptyStateTitleAndClickableText({ + Key key, + this.title, + this.startText, + this.endText, + this.linkText, + this.onPressed, + this.crossAxisAlign, + }) : super(key: key); + + TextSpan _buildTapSpan(String text, TextStyle style, Function() handler) { + return TextSpan(text: text, style: style, recognizer: TapGestureRecognizer()..onTap = handler); + } + + @override + Widget build(BuildContext context) { + + AppTheme theme = context.watch(); + TextStyle style = TextStyles.Body2.textColor(theme.grey).textHeight(1.4); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: crossAxisAlign ?? CrossAxisAlignment.center, + children: [ + VSpace(Insets.l), + Text( + title, + style: TextStyles.T1.textColor(theme.grey), + ), + VSpace(Insets.m), + RichText( + text: TextSpan( + style: style, + children: [ + if (StringUtils.isNotEmpty(startText)) TextSpan(text: startText), + if (StringUtils.isNotEmpty(linkText)) _buildTapSpan(linkText, style.textColor(theme.accent1), onPressed), + if (StringUtils.isNotEmpty(endText)) TextSpan(text: endText), + ], + ), + ), + VSpace(Insets.m * 1.5), + ], + ); + } +} + +class PlaceholderImageAndBgStack extends StatelessWidget { + final String path; + final Widget bgWidget; + final double height; + final double top; + final double left; + + const PlaceholderImageAndBgStack(this.path, {Key key, this.height, this.top, this.left, this.bgWidget}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + overflow: Overflow.visible, + children: [ + bgWidget ?? _BgCircle(), + Image.asset("assets/images/empty-$path@2x.png", height: height).positioned(top: top, left: left), + ], + ); + } +} + +class _BgCircle extends StatelessWidget { + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return StyledContainer(theme.bg2, width: 157, height: 157, borderRadius: BorderRadius.circular(999)); + } +} diff --git a/flokk_src/lib/views/main_scaffold/contact_panel.dart b/flokk_src/lib/views/main_scaffold/contact_panel.dart new file mode 100644 index 0000000..593c171 --- /dev/null +++ b/flokk_src/lib/views/main_scaffold/contact_panel.dart @@ -0,0 +1,108 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/models/contacts_model.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_edit/contact_edit_panel.dart'; +import 'package:flokk/views/contact_info/contact_info_panel.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// Holds the Contact Info and Edit pages, and provides an API to switch between them +class ContactPanel extends StatefulWidget { + final Function() onClosePressed; + final ContactsModel contactsModel; + + const ContactPanel({Key key, this.onClosePressed, this.contactsModel}) : super(key: key); + + @override + ContactPanelState createState() => ContactPanelState(); +} + +class ContactPanelState extends State { + GlobalKey detailsKey = GlobalKey(); + GlobalObjectKey editKey; + ContactData _prevContact; + + bool _isEditingContact = false; + + String _initialEditSection; + + bool get hasUnsavedChanged => _isEditingContact && (editKey?.currentState?.isDirty ?? false); + + void showEditView(String sectionType) { + _initialEditSection = sectionType; + setState(() => _isEditingContact = true); + } + + void showInfoView() { + setState(() => _isEditingContact = false); + } + + void _handleEditPressed(String startSection) => showEditView(startSection); + + void _handleEditComplete(ContactData contact) { + /// If contact is not null, then we want to switch back to the InfoView + if (contact != null) { + showInfoView(); + } + + /// Set selected contact, this will hide the panel if the edit form returns null + context.read().selectedContact = contact; + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + + return StyledContainer( + theme.surface, + borderRadius: BorderRadius.only( + topLeft: Corners.s10Radius, + bottomLeft: Corners.s10Radius, + ), + shadows: Shadows.m(theme.accent1Darker), + child: Consumer( + builder: (_, contact, __) { + /// Create a key from each unique contact to make sure we get state-rebuilds when changing Contact + editKey = GlobalObjectKey(contact ?? ContactData()); + + /// When contact has been set to null, we want to use the prevContact so we get a clean transition out + /// Bit of a hack, but not sure how else to maintain state as we slide out. + contact ??= _prevContact; + if (contact != null) _prevContact = contact; + + /// Anytime we're working on a new contact, we want to be in edit mode + if (contact.isNew) _isEditingContact = true; + + return Provider.value( + /// Pass either the latest contact, or the previous, down the tree + value: contact, + child: (_isEditingContact + ? ContactEditForm( + key: editKey, + initialSection: _initialEditSection, + contact: contact, + contactsModel: widget.contactsModel, + onEditComplete: _handleEditComplete, + ) + : ContactInfoPanel( + key: detailsKey, + onClosePressed: widget.onClosePressed, + onEditPressed: _handleEditPressed, + )) + .constrained( + width: double.infinity, + height: double.infinity, + ) + .padding( + top: Insets.l * .75, + ), + ); + }, + ), + ); + } +} diff --git a/flokk_src/lib/views/main_scaffold/light_dark_toggle_switch.dart b/flokk_src/lib/views/main_scaffold/light_dark_toggle_switch.dart new file mode 100644 index 0000000..097f078 --- /dev/null +++ b/flokk_src/lib/views/main_scaffold/light_dark_toggle_switch.dart @@ -0,0 +1,91 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class LightDarkToggleSwitch extends StatefulWidget { + @override + _LightDarkToggleSwitchState createState() => _LightDarkToggleSwitchState(); +} + +class _LightDarkToggleSwitchState extends State { + int lastSwitchTime = 0; + + void _handleTogglePressed(BuildContext context) { + if (DateTime.now().millisecondsSinceEpoch - lastSwitchTime < Durations.medium.inMilliseconds) { + return; + } + lastSwitchTime = DateTime.now().millisecondsSinceEpoch; + context.read().nextTheme(); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + double iconSize = 18; + double innerWidth = 54; + // Use a stateful builder so we can rebuild ourselves on click without going to a StatefulWidget + return Row( + children: [ + StyledImageIcon(StyledIcons.lightMode, size: iconSize, color: Colors.white), + HSpace(Insets.sm), + Stack(children: [ + StyledContainer( + theme.accent1Darker, + borderRadius: BorderRadius.circular(19), + width: innerWidth, + height: 24, + ), + TweenAnimationBuilder( + tween: Tween(begin: 0, end: theme.isDark ? 1 : 0), + duration: Durations.fastest, + builder: (_, value, __) => StyledContainer( + theme.surface, + duration: Durations.medium, + margin: EdgeInsets.only(top: 2, left: 2 + (innerWidth - 20 - 4) * value, right: 2), + borderRadius: BorderRadius.circular(99), + width: 20, + height: 20, + ), + ), + ]), + HSpace(Insets.sm), + StyledImageIcon(StyledIcons.darkMode, size: iconSize - 2, color: ColorUtils.shiftHsl(theme.accent1, -.1)), + ], + ).clickable(() => _handleTogglePressed(context), opaque: true); + } +} + +class _AnimatedMenuIndicator extends StatefulWidget { + final double indicatorY; + final double width; + final double height; + + _AnimatedMenuIndicator(this.indicatorY, {this.width = 6, this.height = 24}); + + @override + _AnimatedMenuIndicatorState createState() => _AnimatedMenuIndicatorState(); +} + +class _AnimatedMenuIndicatorState extends State<_AnimatedMenuIndicator> { + final double _duration = .5; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return AnimatedContainer( + duration: _duration.seconds, + curve: Curves.easeOutBack, + width: widget.width, + height: widget.height, + child: Container(color: theme.surface), + margin: EdgeInsets.only(top: widget.indicatorY ?? 0)); + } +} diff --git a/flokk_src/lib/views/main_scaffold/main_scaffold.dart b/flokk_src/lib/views/main_scaffold/main_scaffold.dart new file mode 100644 index 0000000..9256273 --- /dev/null +++ b/flokk_src/lib/views/main_scaffold/main_scaffold.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flokk/_internal/components/simple_value_notifier.dart'; +import 'package:flokk/_internal/utils/utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/contacts/refresh_contacts_command.dart'; +import 'package:flokk/commands/dialogs/show_discard_warning_command.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/views/contact_page/contacts_page.dart'; +import 'package:flokk/views/dashboard_page/dashboard_page.dart'; +import 'package:flokk/views/main_scaffold/contact_panel.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold_view.dart'; +import 'package:flokk/views/search/search_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +enum PageType { None, Dashboard, ContactsList } + +class MainScaffold extends StatefulWidget { + static GlobalKey scaffoldKey = GlobalKey(); + static GlobalKey sidePanelKey = GlobalKey(); + static GlobalKey dashboardKey = GlobalKey(); + static GlobalKey contactPageKey = GlobalKey(); + static GlobalKey searchBarKey = GlobalKey(); + + @override + MainScaffoldState createState() => MainScaffoldState(); +} + +/// Handles all button handlers and business logic functions for the MainScaffoldView +/// Also contains any local view state. +class MainScaffoldState extends State { + //Pages + List pages = [ + PageType.Dashboard, + PageType.ContactsList, + ]; + + SimpleValueNotifier> checkedContactsNotifier = SimpleValueNotifier([]); + + AppModel appModel; + + /// Easily lookup the current state of the SidePanel + ContactPanelState get contactsPanel => MainScaffold.sidePanelKey.currentState; + + SearchBarState get searchBar => MainScaffold.searchBarKey.currentState; + + /// Disable scaffold animations, used when changing pages, so the new page does not animate in + bool skipScaffoldAnims = false; + + @override + void initState() { + /// Get a reference to the app model + appModel = context.read(); + + /// Change current page + Future.microtask(() => trySetCurrentPage(PageType.Dashboard, false)); + super.initState(); + } + + /// Setting an empty id will trigger the Create User panel to open + void addNew() async { + if (!await showDiscardWarningIfNecessary()) return; + searchBar?.cancel(); + + /// FutureUser Case: Need to jump from mainScaffold, into any contact and edit. + appModel.selectedContact = ContactData(); + } + + void editSelectedContact(String section) => contactsPanel.showEditView(section); + + /// Attempt to change current page, this might not complete if user is currently editing + Future trySetCurrentPage(PageType t, [bool refresh = true]) async { + if (t == appModel.currentMainPage) return; + + // Show a Ok/Cancel dialog if the user has un-saved edits. + // Exit early if the user chooses to cancel the page change. + if (!await showDiscardWarningIfNecessary()) return; + + // Change page + appModel.currentMainPage = t; + + // Close SearchBar if it's open + searchBar?.cancel(); + + //Skip Scaffold animations if the editPanel is currently open, we don't want the new page animating with the closing panel + if (appModel.selectedContact != null) skipScaffoldAnims = true; + + //Clear any selected contact, causing the editPanel to close + appModel.selectedContact = null; + + // Clear any checked contacts + checkedContactsNotifier.value = []; + + //Refresh each time we change pages + if (refresh) { + RefreshContactsCommand(context).execute(); + } + } + + /// Change selected contact, this might not complete if user is currently editing + Future trySetSelectedContact(ContactData value, {showSocial = false}) async { + if (!await showDiscardWarningIfNecessary()) return; + //De-select? + bool hasSocialChanged = showSocial != appModel.showSocialTabOnInfoView; + if (!hasSocialChanged && appModel.selectedContact != null && appModel.selectedContact?.id == value?.id) { + value = null; + } + appModel.selectedContact = value; + appModel.showSocialTabOnInfoView = showSocial; + contactsPanel?.showInfoView(); + } + + /// Change checked contacts + Future setCheckedContact(ContactData contact, bool value) async { + List checked = checkedContactsNotifier.value; + if (value) { + checked.add(contact); + } else { + checked.removeWhere((element) => element.id == contact.id); + } + checkedContactsNotifier.notify(); + } + + Future showDiscardWarningIfNecessary() async { + // If there are no actual changes, no need to bug the user. + if (!(contactsPanel?.hasUnsavedChanged ?? false)) return true; + // Ask user if they'd like to discard + return await ShowDiscardWarningCommand(context).execute(); + } + + // Avoids a layout error thrown by Flutter when scaffold is forced to close while re-sizing the window + void closeScaffoldOnResize() { + if (MainScaffold.scaffoldKey.currentState?.isDrawerOpen ?? false) { + scheduleMicrotask(() => Navigator.pop(context)); + } + } + + void openMenu() => MainScaffold.scaffoldKey.currentState.openDrawer(); + + void handleBgTapped() { + Utils.unFocus(); + } + + void handleSearchSubmit() { + // When a search is submitted, try and navigate to the contactsView + trySetCurrentPage(PageType.ContactsList); + // [SB] Hack to try and get more reliable scroll-bar sizing when submitting. + //TODO: Confirm this bug still exists (scrollbar can stay stuck on, even when there are no results) + Future.delayed(1000.milliseconds, () => setState(() {})); + } + + /// Provide this state to all the views below it, enabling child-widgets to request page changes, select a certain contact, + /// or make other requests on MainScaffold to do something. + @override + Widget build(BuildContext context) => Provider.value( + value: this, + child: MainScaffoldView(this), + ); + +} diff --git a/flokk_src/lib/views/main_scaffold/main_scaffold_view.dart b/flokk_src/lib/views/main_scaffold/main_scaffold_view.dart new file mode 100644 index 0000000..d583247 --- /dev/null +++ b/flokk_src/lib/views/main_scaffold/main_scaffold_view.dart @@ -0,0 +1,224 @@ + +import 'package:flokk/_internal/components/fading_index_stack.dart'; +import 'package:flokk/_internal/widget_view.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/styled_components/flokk_logo.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/contact_page/contacts_page.dart'; +import 'package:flokk/views/dashboard_page/dashboard_page.dart'; +import 'package:flokk/views/main_scaffold/contact_panel.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flokk/views/main_scaffold/main_side_menu.dart'; +import 'package:flokk/views/search/search_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:universal_platform/universal_platform.dart'; + +class MainScaffoldView extends WidgetView { + MainScaffoldView(MainScaffoldState state) : super(state); + + @override + Widget build(BuildContext context) { + /// /////////////////////////////////////////////////////////// + /// Bind to AppModel when selectedContact changes, and provide it to the sub-tree + ContactData selectedContact = context.select((model) => model.selectedContact); + + /// Bind to page-change + var currentPage = context.select((value) => value.currentMainPage); + + /// Flutter throws an error when it's forced to close the drawer on resize, so pre-emptively close it + state.closeScaffoldOnResize(); + + /// ///////////////////////////////////////////////// + /// RESPONSIVE LAYOUT LOGIC + + /// Calculate Left Menu Size + double leftMenuWidth = Sizes.sideBarSm; + bool skinnyMenuMode = true; + if (context.widthPx >= PageBreaks.Desktop) { + leftMenuWidth = Sizes.sideBarLg; + skinnyMenuMode = false; + } else if (context.widthPx > PageBreaks.TabletLandscape) { + leftMenuWidth = Sizes.sideBarMed; + } + + /// Calculate Right panel size + double detailsPanelWidth = 400; + if (context.widthInches > 8) { + //Panel size gets a little bigger as the screen size grows + detailsPanelWidth += (context.widthInches - 8) * 12; + } + + bool isNarrow = context.widthPx < PageBreaks.TabletPortrait; + + /// Calculate Top bar height + double topBarHeight = 60; + double topBarPadding = isNarrow ? Insets.m : Insets.l; + + /// Figure out what should be visible, and the size of our viewport + /// 3 cases: 1) Single Column, 2) LeftMenu + Single Column, 3) LeftMenu + Dual Column + /// (Dual Column means it can show both ContentArea and EditPanel at the same time) + bool showPanel = selectedContact != null; //Contact panel is always shown if a contact is selected + bool showLeftMenu = !isNarrow; //Whether main menu is shown, or hidden behind hamburger btn + bool useSingleColumn = context.widthInches < 10; //Whether detail panel fills the entire content area + bool hideContent = showPanel && useSingleColumn; //If single column + panel, we can hide the content + double leftContentOffset = showLeftMenu ? leftMenuWidth : Insets.mGutter; //Left position for the main content stack + double contentRightPos = showPanel ? detailsPanelWidth : 0; //Right position for main content stack + + /// Sometimes we want to skip the layout animations, for example, when we're changing main pages , + /// we want the new page to ignore the panel that is sliding out of the view. + Duration animDuration = state.skipScaffoldAnims ? .01.seconds : .35.seconds; + state.skipScaffoldAnims = false; // Reset flag so we only skip animations for one build cycle + if (UniversalPlatform.isWeb && !AppModel.enableAnimationsOnWeb) { + animDuration = .0.seconds; + } + + /// ///////////////////////////////////////////////// + /// CONTENT WIDGETS + + /// Edit Panel + Widget editPanel = ContactPanel( + key: MainScaffold.sidePanelKey, + onClosePressed: () => state.trySetSelectedContact(null), + contactsModel: state.appModel.contactsModel, + ); + editPanel = RepaintBoundary(child: editPanel); + editPanel = FocusTraversalGroup(child: editPanel); + + /// Search Bar + Widget searchBar = SearchBar( + key: MainScaffold.searchBarKey, + closedHeight: topBarHeight, + narrowMode: !showLeftMenu, + searchEngine: state.appModel.searchEngine, + onContactPressed: (c) => state.trySetSelectedContact(c, showSocial: false), + onSearchSubmitted: state.handleSearchSubmit, + ); + searchBar = RepaintBoundary(child: searchBar); + searchBar = FocusTraversalGroup(child: searchBar); + + /// Main content page stack + Widget contentStack = FadingIndexedStack( + index: state.pages.indexOf(currentPage), + children: [ + /// DASHBOARD PAGE + DashboardPage( + selectedContact: selectedContact, + ), + + /// CONTACTS PAGE + ValueListenableBuilder>( + valueListenable: state.checkedContactsNotifier, + builder: (_, checkedContacts, __) { + return ContactsPage( + //key: MainScaffold.contactPageKey, + selectedContact: selectedContact, + checkedContacts: checkedContacts, + searchEngine: state.appModel.searchEngine, + //onContactSelected: state.setSelectedContact, + ) + //Asymmetric padding for the ListView, as we need to leave room in the gutter for the scroll bar + .padding(left: Insets.lGutter, right: Insets.mGutter); + }, + ), + ], + ); + //contentStack = RepaintBoundary(child: contentStack); + contentStack = FocusTraversalGroup(child: contentStack); + + /// ///////////////////////////////////////////////// + /// BUILD + AppTheme theme = context.watch(); + return Provider.value( + /// Provide the currently selected contact to all views below this + value: selectedContact, + child: Scaffold( + key: MainScaffold.scaffoldKey, + + /// If menu is hidden, pass it to the .drawer property of scaffold. + /// Assign a max width since it will be unconstrained in overlay mode and we don't want it to fill the entire screen. + drawer: showLeftMenu + ? null + : MainSideMenu( + onPageSelected: state.trySetCurrentPage, + onAddNewPressed: state.addNew, + ).constrained(maxWidth: Sizes.sideBarLg), + body: + //Main App Bg + StyledContainer( + theme.bg1, + child: Stack( + children: [ + Stack(children: [ + /// ///////////////////////////////////////////////// + /// INNER CONTENT STACK + contentStack.padding(top: topBarHeight + topBarPadding), + + /// ///////////////////////////////////////////////// + /// HAMBURGER MENU BTN + IconButton(icon: Icon(Icons.menu, size: 24, color: theme.accent1), onPressed: state.openMenu) + .animatedPanelX(closeX: -50, isClosed: showLeftMenu) + .positioned(left: Insets.m, top: Insets.m), + + /// Flokk Logo, Top-Center, only shown in narrow mode + if (isNarrow) FlokkLogo(40, theme.accent1).alignment(Alignment.topCenter).padding(top: Insets.l), + + /// ///////////////////////////////////////////////// + /// SEARCH BAR + searchBar.constrained(minHeight: topBarHeight), + ]) // Shared styling for the entire content area (content + search) + .constrained(minWidth: 500) + .opacity(hideContent ? 0 : 1, animate: true) + .positioned(left: leftContentOffset, right: contentRightPos, bottom: 0, top: 0, animate: true) + .animate(animDuration, Curves.easeOut), + + /// ///////////////////////////////////////////////// + /// LEFT MENU + /// This is defined in the tree unlike the other elements because actually want it to exist twice + /// This menu may be animating out, while the other version exists in the app drawer + MainSideMenu( + onPageSelected: state.trySetCurrentPage, + onAddNewPressed: state.addNew, + skinnyMode: skinnyMenuMode, + ) + .animatedPanelX( + closeX: -leftMenuWidth, + // Rely on the animatedPanel to toggle visibility of this when it's hidden. It renders an empty Container() when closed + isClosed: !showLeftMenu, + ) // Styling, pin to left, fixed width + .positioned(left: 0, top: 0, width: leftMenuWidth, bottom: 0, animate: true) + .animate(animDuration, Curves.easeOut), + + /// ///////////////////////////////////////////////// + /// RIGHT PANEL - Layout editPanel in 1 of 2 ways, single or double column + !useSingleColumn + + /// Dual-column mode: the edit panel is a fixed width + ? editPanel + .animatedPanelX( + duration: animDuration.inMilliseconds * .001, + closeX: detailsPanelWidth, + isClosed: !showPanel, + ) // Styling: Pin to right, using a fixed-width for the panel + .positioned(right: 0, width: detailsPanelWidth, top: 0, bottom: 0) + //.animate(animDuration, Curves.easeOut) + + /// Single-column mode: the edit panel is the entire width, minus the left-menu + : editPanel + .animatedPanelX( + closeX: context.widthPx - leftContentOffset, + isClosed: !showPanel, + ) // Styling: Pin to left instead of right for better window resizing, allow panel to stretch as needed + .padding(left: leftContentOffset, animate: true) + .animate(animDuration, Curves.easeOut), + ], + ), + ), + ).gestures(onTap: state.handleBgTapped), + ); + } +} diff --git a/flokk_src/lib/views/main_scaffold/main_side_menu.dart b/flokk_src/lib/views/main_scaffold/main_side_menu.dart new file mode 100644 index 0000000..1791e70 --- /dev/null +++ b/flokk_src/lib/views/main_scaffold/main_side_menu.dart @@ -0,0 +1,214 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/color_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/commands/logout_command.dart'; +import 'package:flokk/models/app_model.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/flokk_logo.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/light_dark_toggle_switch.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flokk/views/main_scaffold/main_side_menu_btn.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MainMenuOffsetNotification extends Notification { + final Offset offset; + final PageType pageType; + + MainMenuOffsetNotification(this.pageType, this.offset); +} + +class MainSideMenu extends StatefulWidget { + final Function(PageType t) onPageSelected; + final Function() onAddNewPressed; + final bool skinnyMode; + + const MainSideMenu({Key key, this.onPageSelected, this.onAddNewPressed, this.skinnyMode = false}) : super(key: key); + + @override + _MainSideMenuState createState() => _MainSideMenuState(); +} + +class _MainSideMenuState extends State { + + Map _menuBtnOffsetsByType = {}; + PageType _prevPage; + + double get _headerHeight => 106; + + double get _indicatorHeight => 48; + + double get _btnHeight => 60; + + double _indicatorY; + + @override + void initState() { + //PageType p = PageType.Dashboard; + Future.delayed(100.milliseconds).then((value) { + _updateIndicatorState(context.read().currentMainPage); + }); + super.initState(); + } + + void _handleLogoutPressed() => LogoutCommand(context).execute(doConfirm: true); + + void _handlePageSelected(PageType pageType) => widget.onPageSelected?.call(pageType); + + void _updateIndicatorState(PageType type) { + if (_menuBtnOffsetsByType.containsKey(type)) { + Offset o = _menuBtnOffsetsByType[type]; + setState(() => _indicatorY = o.dy - _headerHeight + _btnHeight * .5 - _indicatorHeight * .5); + } + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + String versionNum = context.select((m) => m.version); + /// Bind to AppModel when currentPage changes + var currentPage = context.select((value) => value.currentMainPage); + if (currentPage != _prevPage) { + _updateIndicatorState(currentPage); + } + _prevPage = currentPage; + Color bgColor = theme.isDark? ColorUtils.blend(theme.bg1, theme.accent1, .08) : theme.accent1; + return FocusTraversalGroup( + child: Container( + child: Column( + children: [ + /// //////////////////////////////////////////////////////// + /// HEADER + Stack( + children: [ + // Background layer, scaled a bit on the Y axis so it under-hangs the menu below + // This opaque background is only needed when the menu is in the slide-out drawer state + StyledContainer(theme.bg1).transform(transform: Matrix4.diagonal3Values(1.0, 1.2, 1.0)), + + /// //////////////////////////////////////////////// + /// Main Flock Logo + FlokkSidebarLogo(widget.skinnyMode).center(), + //Text("APP NAME", style: TextStyles.T1).textColor(theme.accent1Dark).center(), + ], + ).height(_headerHeight), + + /// //////////////////////////////////////////////////////// + /// MENU + Stack( + children: [ + /// Menu-Background + StyledContainer(bgColor, borderRadius: BorderRadius.only(topRight: Corners.s10Radius)), + + /// Version + Text("v$versionNum", style: TextStyles.Caption.textColor(Colors.white)).positioned(left: 4, bottom: 4), + + /// //////////////////////////////////////////////////////// + /// Buttons + NotificationListener( + // Listen for [MainMenuOffsetNotification], dispatched from each [MainMenuBtn] that is assigned a pageType. + // We use these to position the animated indicator in [_updateIndicatorState] + onNotification: (n) { + _menuBtnOffsetsByType[n.pageType] = n.offset; + return true; // Return true so the notification stops here + }, + child: Column( + children: [ + VSpace(Insets.l), + + /// New Contact Btn + MainMenuBtn(StyledIcons.add, "Create Contact", + compact: widget.skinnyMode, + height: _btnHeight, + transparent: false, + iconSize: 20, + isSelected: true, + dottedBorder: true, + onPressed: () => widget.onAddNewPressed()), + + VSpace(Insets.l), + + /// Dashboard Btn + MainMenuBtn( + StyledIcons.dashboard, + "DASHBOARD", + compact: widget.skinnyMode, + pageType: PageType.Dashboard, + height: _btnHeight, + isSelected: currentPage == PageType.Dashboard, + onPressed: () => _handlePageSelected(PageType.Dashboard), + ), + + /// Contacts Out Btn + MainMenuBtn( + StyledIcons.user, + "CONTACTS", + compact: widget.skinnyMode, + pageType: PageType.ContactsList, + height: _btnHeight, + isSelected: currentPage == PageType.ContactsList, + onPressed: () => _handlePageSelected(PageType.ContactsList), + ), + + Spacer(), + + /// Light / Dark Toggle + //Use a row to easily center the Toggle inside the column + [ + LightDarkToggleSwitch(), + ].toRow(mainAxisAlignment: MainAxisAlignment.center), + + VSpace(Insets.m), + + /// Sign Out Btn + TransparentBtn( + hoverColor: theme.txt.withOpacity(.05), + contentPadding: EdgeInsets.all(Insets.m), + child: Text("SIGN OUT", style: TextStyles.Btn.textColor(Colors.white)), + onPressed: _handleLogoutPressed, + ), + + ], + )).padding(all: Insets.l, bottom: Insets.m).constrained(maxWidth: 280), + + /// Animated line that moves up and down to select the current page + _AnimatedMenuIndicator(_indicatorY ?? 0, height: _indicatorHeight) + ], + ).flexible(), + ], + ), + ), + ); + } +} + +class _AnimatedMenuIndicator extends StatefulWidget { + final double indicatorY; + final double width; + final double height; + + _AnimatedMenuIndicator(this.indicatorY, {this.width = 6, this.height = 24}); + + @override + _AnimatedMenuIndicatorState createState() => _AnimatedMenuIndicatorState(); +} + +class _AnimatedMenuIndicatorState extends State<_AnimatedMenuIndicator> { + final double _duration = .5; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return AnimatedContainer( + duration: _duration.seconds, + curve: Curves.easeOutBack, + width: widget.width, + height: widget.height, + child: StyledContainer(theme.surface), + margin: EdgeInsets.only(top: widget.indicatorY ?? 0)); + } +} diff --git a/flokk_src/lib/views/main_scaffold/main_side_menu_btn.dart b/flokk_src/lib/views/main_scaffold/main_side_menu_btn.dart new file mode 100644 index 0000000..c3505a6 --- /dev/null +++ b/flokk_src/lib/views/main_scaffold/main_side_menu_btn.dart @@ -0,0 +1,96 @@ +import 'package:dotted_border/dotted_border.dart'; +import 'package:flokk/_internal/components/one_line_text.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/build_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flokk/views/main_scaffold/main_side_menu.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MainMenuBtn extends StatefulWidget { + final AssetImage icon; + final String label; + final VoidCallback onPressed; + final bool isSelected; + final double iconSize; + final bool compact; + final bool transparent; + final double height; + final PageType pageType; + final bool dottedBorder; + + MainMenuBtn(this.icon, this.label, + {Key key, + this.onPressed, + this.isSelected = false, + this.iconSize = 26, + this.compact = false, + this.transparent = true, + this.height = 60, + this.pageType = PageType.None, this.dottedBorder = false}) + : assert((icon is AssetImage) || (icon is IconData)), + super(key: key); + + @override + MainMenuBtnState createState() => MainMenuBtnState(); +} + +class MainMenuBtnState extends State { + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + + /// If we have a pageType, send a notification to the parent menu, so it know the position of each btn, and can position it's current-page indicator + if (widget.pageType != PageType.None) { + Future.delayed(1.milliseconds, () { + Offset o = BuildUtils.getOffsetFromContext(context); + MainMenuOffsetNotification(widget.pageType, o).dispatch(context); + }); + } + + /// Create the Icon / Text Row that animates opacity when selected and hides text when compactMode = true + TextStyle btnStyle = TextStyles.Btn; + Widget btnContents = Row( + mainAxisAlignment: widget.compact ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + if (!widget.compact) HSpace(Insets.l), + Padding( + padding: EdgeInsets.all(2.0), + child: StyledImageIcon(widget.icon, size: widget.iconSize - 4.0, color: Colors.white), + ), + if (!widget.compact)... { + HSpace(Insets.l * .5), + OneLineText(widget.label.toUpperCase(), style: btnStyle).flexible() + } + ], + ).height(widget.height).opacity(widget.isSelected ? 1 : .8, animate: true).animate(.3.seconds, Curves.easeOut); + + + //Wrap btn in a border... maybe + btnContents = widget.dottedBorder? DottedBorder( + dashPattern: [3, 5], + color: Colors.white.withOpacity(.7), + borderType: widget.compact? BorderType.Circle : BorderType.RRect, + radius: Corners.s8Radius, + child: Center(child: btnContents)) : btnContents; + + /// Wrap contents in a btn + return RawMaterialButton( + textStyle: (widget.isSelected ? TextStyles.BtnSelected : TextStyles.Btn).textColor(Colors.white), + fillColor: Colors.transparent, + highlightColor: Colors.white.withOpacity(.1), + focusElevation: 0, + hoverElevation: 0, + highlightElevation: 0, + elevation: 0, + padding: EdgeInsets.zero, + shape: widget.compact ? CircleBorder() : RoundedRectangleBorder(borderRadius: Corners.s8Border), + onPressed: widget.onPressed, + child: btnContents); + + } +} diff --git a/flokk_src/lib/views/search/search_bar.dart b/flokk_src/lib/views/search/search_bar.dart new file mode 100644 index 0000000..d6bb46b --- /dev/null +++ b/flokk_src/lib/views/search/search_bar.dart @@ -0,0 +1,175 @@ +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/views/main_scaffold/main_scaffold.dart'; +import 'package:flokk/views/search/search_bar_view.dart'; +import 'package:flokk/views/search/search_engine.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class SearchBar extends StatefulWidget { + final Function(ContactData) onContactPressed; + final Function() onSearchSubmitted; + final double closedHeight; + final SearchEngine searchEngine; + final bool narrowMode; + final double topPadding; + + const SearchBar({ + Key key, + this.searchEngine, + this.closedHeight, + this.onContactPressed, + this.onSearchSubmitted, + this.narrowMode, + this.topPadding, + }) : super(key: key); + + @override + SearchBarState createState() => SearchBarState(); +} + +class SearchBarState extends State { + final SearchEngine tmpSearch = SearchEngine(); + final GlobalKey textKey = GlobalKey(); + final GlobalKey resultsColumnKey = GlobalKey(); + + FocusNode textFocusNode; + + @override + void initState() { + RawKeyboard.instance.addListener(_handleRawKeyPressed); + super.initState(); + } + + @override + void dispose() { + RawKeyboard.instance.removeListener(_handleRawKeyPressed); + super.dispose(); + } + + bool _isOpen = false; + + bool get isOpen => _isOpen; + + double _resultsHeight = 0; + + double get resultsHeight => _resultsHeight; + + set resultsHeight(double height) { + if (_resultsHeight != height) setState(() => _resultsHeight = height); + } + + bool get hasQuery => tmpSearch.hasQuery; + + set isOpen(bool value) { + if (_isOpen == value) return; + // When we open up, make a copy of the current search settings to work with + if (value == true) { + // Put the focus in the text field to start + textFocusNode?.requestFocus(); + } else { + // Unfocus textNode when closing + textFocusNode.unfocus(); + } + // Anytime we toggle, make sure the search bar contents match the main searchEngine, + // this will either revert the un-submitted changes, or do nothing. + tmpSearch.copyFrom(widget.searchEngine); + textKey?.currentState?.text = tmpSearch.query ?? ""; + + setState(() => _isOpen = value); + } + + void cancel() => isOpen = false; + + void handleSearchChanged(String value) => tmpSearch.query = value; + + // Expand when we get focus + void handleFocusChanged(bool value) { + if (value || !hasQuery) isOpen = value; + } + + void handleSearchSubmitted() { + /// Copy the tmp values back into the main search engine, triggering a rebuild in the main list, + save(); + widget.onSearchSubmitted?.call(); + isOpen = false; + } + + void handleContactPressed(ContactData c) async { + MainScaffoldState scaffold = context.read(); + if (!await scaffold.showDiscardWarningIfNecessary()) + return; + tmpSearch.addFilterContact(c.nameFull); + clearQueryString(); + handleSearchSubmitted(); + Future.microtask(() => scaffold.trySetSelectedContact(c)); + } + + void handleTagPressed(String tag) { + tmpSearch.addTag(tag); + clearQueryString(); + handleSearchSubmitted(); + } + + void _handleRawKeyPressed(RawKeyEvent evt) { + if (evt is RawKeyDownEvent) { + if (evt.logicalKey == LogicalKeyboardKey.keyK && evt.isControlPressed) { + isOpen = true; + } else if (textFocusNode.hasFocus && evt.logicalKey == LogicalKeyboardKey.enter) { + handleSearchSubmitted(); + } else if (textFocusNode.hasFocus && evt.logicalKey == LogicalKeyboardKey.backspace) { + if (textKey != null && textKey.currentState != null && textKey.currentState.text.isEmpty) { + final tl = tmpSearch.tagList; + final cl = tmpSearch.filterContactList; + if (cl.isNotEmpty) { + tmpSearch.removeFilterContact(cl.last); + } else if (tl.isNotEmpty) { + tmpSearch.removeTag(tl.last); + } + } + } + } + } + + void handleTextFocusCreated(FocusNode node) { + textFocusNode = node; + } + + void handleRemoveTag(String tag) { + tmpSearch.removeTag(tag); + if (!isOpen) save(); + } + + void handleRemoveFilterContact(String filterContact) { + tmpSearch.removeFilterContact(filterContact); + if (!isOpen) save(); + } + + void handleSearchIconPressed() => textFocusNode?.requestFocus(); + + void clearQueryString() { + handleSearchChanged(""); + textKey?.currentState?.text = ""; + textFocusNode.requestFocus(); + } + + void clearSearch() { + handleSearchChanged(""); + textKey?.currentState?.text = ""; + tmpSearch.clearTags(); + tmpSearch.clearFilterContacts(); + if (!isOpen) + save(); + else + textFocusNode.requestFocus(); + } + + void save() { + widget.searchEngine.copyFrom(tmpSearch); + } + + @override + Widget build(BuildContext context) => SearchBarView(this); + +} diff --git a/flokk_src/lib/views/search/search_bar_view.dart b/flokk_src/lib/views/search/search_bar_view.dart new file mode 100644 index 0000000..e3c3acf --- /dev/null +++ b/flokk_src/lib/views/search/search_bar_view.dart @@ -0,0 +1,112 @@ +import 'dart:math'; + +import 'package:flokk/_internal/components/content_underlay.dart'; +import 'package:flokk/_internal/widget_view.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/opening_divider.dart'; +import 'package:flokk/styled_components/styled_container.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/search/search_bar.dart'; +import 'package:flokk/views/search/search_query_results.dart'; +import 'package:flokk/views/search/search_query_row.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class SearchBarView extends WidgetView { + + SearchBarView(SearchBarState state, {Key key}) : super(state, key: key); + + bool get isOpen => state.isOpen; + + + bool _handleKeyPress(FocusNode node, RawKeyEvent evt) { + if (evt is RawKeyDownEvent) { + if (evt.logicalKey == LogicalKeyboardKey.escape) { + state.cancel(); + return true; + } + } + return false; + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + + // Fixed content height plus top and bottom padding + double barHeight = 30 + Insets.m * 1.25 * 2.0; + double topPadding = state.widget.narrowMode? Insets.m : Insets.l; + double leftPadding = state.widget.narrowMode? 50 : 0; // Move over to not overlay with main-app menu btn + /// CONTENT UNDERLAY + Widget underlay = ContentUnderlay( + isActive: isOpen && state.resultsHeight > 0, + color: theme.bg1.withOpacity(.7), + ); + return FocusScope( + onKey: _handleKeyPress, + child: Stack( + children: [ + /// Clickable underlay, closes on press + underlay.gestures(onTap: state.cancel), + /// Wrap content in an animated card, this will handle open and closing animations + _AnimatedSearchCard( + state, + // Content Column + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Search Box + SearchQueryRow(state), + // Search Results + SearchResults(state) + // Fade search results out when we're not open + .opacity(isOpen ? 1 : 0, animate: true) + .animate(Durations.fast, Curves.easeOut) + .expanded() + ], + ), + ).padding(left: Insets.lGutter + leftPadding, right: Insets.lGutter, vertical: topPadding), + + /// Animated Search Underline + if (state.resultsHeight > 0) ...{ + Positioned( + top: topPadding + barHeight - 6, + left: Insets.lGutter + leftPadding + Insets.l, + right: Insets.lGutter + Insets.l, + child: OpeningDivider(isOpen: isOpen), + ), + }, + + ], + ), + ); + } +} + +/// Handles the transition from open and closed, the content is a Column, contains the SearchBox, and SearchResults +class _AnimatedSearchCard extends StatelessWidget { + final Widget child; + final SearchBarState searchBar; + + const _AnimatedSearchCard(this.searchBar, {Key key, this.child}) : super(key: key); + + bool get isOpen => searchBar.isOpen; + + bool get hasQuery => searchBar.hasQuery; + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + double openHeight = min(searchBar.widget.closedHeight + searchBar.resultsHeight + 1, 600); + return StyledContainer(isOpen || hasQuery ? theme.surface : Color(0x00ffffff), + height: isOpen ? openHeight : searchBar.widget.closedHeight, + borderRadius: BorderRadius.circular(6), + duration: Durations.fast, + //border: Border.all(color: Colors.grey.withOpacity(isOpen ? .3 : 0)), + shadows: isOpen || hasQuery ? Shadows.m(theme.accent1Darker) : [], + child: child); + } +} diff --git a/flokk_src/lib/views/search/search_engine.dart b/flokk_src/lib/views/search/search_engine.dart new file mode 100644 index 0000000..cd48f3c --- /dev/null +++ b/flokk_src/lib/views/search/search_engine.dart @@ -0,0 +1,189 @@ +import 'package:flokk/_internal/components/simple_value_notifier.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/_internal/utils/utils.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/data/group_data.dart'; + +class SearchEngine extends SimpleNotifier { + bool _isDirty = false; + List _cachedResults; + List _tagCache; + + get hasQuery => (query?.isNotEmpty ?? false) || tags.isNotEmpty || filterContacts.isNotEmpty; + + SearchEngine copy() => SearchEngine()..copyFrom(this); + + void copyFrom(SearchEngine se) { + _query = se.query; + _tags = se.tags; + _filterContacts = se.filterContacts; + _orderDesc = se.orderDesc; + _contactsList = se.contactsList; + _groupsList = se.groupsList; + _orderBy = se._orderBy; + _isDirty = true; + notifyListeners(); + } + + /// //////////////////////////////////////////// + /// QUERY STRING + String get query => _query; + + set query(String query) => setAndMarkDirty(() => _query = query); + String _query; + + /// //////////////////////////////////////////// + /// Filter tags + String get tags => _tags ?? ""; + + set tags(String tags) => setAndMarkDirty(() => _tags = tags); + + List get tagList => tags.split(",").where((e) => e.isNotEmpty).toList(); + + void addTag(String tag) { + final tl = tagList; + if (!tl.contains(tag)) { + tl.add(tag); + tags = tl.join(","); + } + } + + void removeTag(String tag) { + final tl = tagList; + if (tl.remove(tag)) { + tags = tl.join(","); + } + } + + void clearTags() => tags = ""; + + /// _tags is a comma separated list of tags + String _tags; + + /// //////////////////////////////////////////// + /// Filter contacts + String get filterContacts => _filterContacts ?? ""; + + set filterContacts(String filterContacts) => setAndMarkDirty(() => _filterContacts = filterContacts); + + List get filterContactList => filterContacts.split(",").where((e) => e.isNotEmpty).toList(); + + void addFilterContact(String filterContact) { + final fcl = filterContactList; + if (!fcl.contains(filterContact)) { + fcl.add(filterContact); + filterContacts = fcl.join(","); + } + } + + void removeFilterContact(String filterContact) { + final fcl = filterContactList; + if (fcl.remove(filterContact)) { + filterContacts = fcl.join(","); + } + } + + void clearFilterContacts() => filterContacts = ""; + + String _filterContacts; + + /// //////////////////////////////////////////// + /// ORDER BY + ContactOrderBy get orderBy => _orderBy; + + set orderBy(ContactOrderBy orderBy) => setAndMarkDirty(() => _orderBy = orderBy); + ContactOrderBy _orderBy = ContactOrderBy.FirstName; + + /// //////////////////////////////////////////// + /// ORDER DESCENDING + bool get orderDesc => _orderDesc; + + set orderDesc(bool value) => setAndMarkDirty(() => _orderDesc = value); + bool _orderDesc = false; + + /// //////////////////////////////////////////// + /// DATA SOURCE + List get contactsList => _contactsList; + + set contactsList(List value) => setAndMarkDirty(() => _contactsList = value); + List _contactsList; + + List get groupsList => _groupsList; + + set groupsList(List value) => setAndMarkDirty(() => _groupsList = value); + List _groupsList; + + List getResults([List newContacts, ContactOrderBy _orderBy]) { + if (newContacts != null) contactsList = newContacts; + if (_orderBy != null) orderBy = _orderBy; + // If we have no data + if (_contactsList == null) return []; + _updateCache(); + return _cachedResults ?? []; + } + + List getTagResults() { + if (query == null || query.isEmpty) return []; + _updateCache(); + return _tagCache ?? []; + } + + List _buildGroups() { + return _groupsList.map((g) => g.name).toList(); + } + + void _updateCache() { + if (!_isDirty) return; + + Utils.benchmark("Search - Sorted and Filtered", () { + List results = List.from(_contactsList); + List groups = _buildGroups(); + { + final lowerQuery = !StringUtils.isEmpty(_query) ? _query.toLowerCase() : ""; + if (hasQuery) { + results.retainWhere((c) { + bool matchsGroups = false; + for (var g in tagList) { + if (c.searchable.contains(g.toLowerCase())) { + matchsGroups = true; + break; + } + } + bool matchsContact = false; + for (var g in filterContactList) { + if (c.searchable.contains(g.toLowerCase())) { + matchsContact = true; + break; + } + } + bool queryNotEmpty = lowerQuery.isNotEmpty; + //bool hasTags = tagList.isNotEmpty || filterContactList.isNotEmpty; + bool matchsTags = matchsGroups || matchsContact; + return (queryNotEmpty && c.searchable.contains(lowerQuery)) || matchsTags; + }); + } + groups.retainWhere((g) { + return g.toLowerCase().contains(lowerQuery) && !tags.contains(g); + }); + } + if (orderBy == ContactOrderBy.FirstName) { + results.sort((a, b) => _nullSafeSort(a.nameGiven, b.nameGiven, _orderDesc)); + } else if (orderBy == ContactOrderBy.LastName) { + results.sort((a, b) => _nullSafeSort(a.nameFamily, b.nameFamily, _orderDesc)); + } + _cachedResults = results; + _tagCache = groups; + _isDirty = false; + }); + } + + int _nullSafeSort(String a, String b, bool orderDesc) { + return (a ?? "").compareTo(b ?? "") * (orderDesc ? -1 : 1); + } + + void setAndMarkDirty(Function() setter) { + setter(); + _isDirty = true; + notifyListeners(); + } +} diff --git a/flokk_src/lib/views/search/search_query_results.dart b/flokk_src/lib/views/search/search_query_results.dart new file mode 100644 index 0000000..2862476 --- /dev/null +++ b/flokk_src/lib/views/search/search_query_results.dart @@ -0,0 +1,171 @@ +import 'dart:math'; + +import 'package:flokk/_internal/components/listenable_builder.dart'; +import 'package:flokk/_internal/components/simple_grid.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/build_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/data/contact_data.dart'; +import 'package:flokk/styled_components/buttons/transparent_btn.dart'; +import 'package:flokk/styled_components/scrolling/styled_scrollview.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styled_components/styled_label_pill.dart'; +import 'package:flokk/styled_components/styled_user_avatar.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/search/search_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +/// SEARCH RESULTS LIST +/// Binds to notifications from the tmpSearchEngine using ListenableBuilder +class SearchResults extends StatelessWidget { + final SearchBarState state; + + const SearchResults(this.state, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return LayoutBuilder( + builder: (_, constraints) { + return ListenableBuilder( + listenable: state.tmpSearch, + builder: (_, __) { + //Add a callback hook, so the column can pass us it's size after layout. + BuildUtils.getFutureSizeFromGlobalKey(state.resultsColumnKey, (size) => state.resultsHeight = size.height); + + int maxResults = 6; + int colCount = max(1, (constraints.maxWidth / 280).floor()).clamp(1, maxResults); + + /// Create Result Items + List contacts = state.tmpSearch.hasQuery ? state.tmpSearch.getResults() : []; + + List tags = state.tmpSearch.getTagResults(); + final List labelPills = tags + .take(maxResults) + .map((tag) => StyledLabelPill(tag.toUpperCase(), + textStyle: TextStyles.Footnote.textColor(theme.grey).letterSpace(0).textHeight(1.63), + borderRadius: Corners.s5, onPressed: () => state.handleTagPressed(tag))) + .toList(); + + final List<_ContactSearchListItem> contactListItems = contacts + .take(maxResults) + .map((c) => _ContactSearchListItem( + contact: c, + onPressed: () => state.handleContactPressed(c), + )) + .toList(); + + /// Layout + return StyledScrollView( + child: Column( + key: state.resultsColumnKey, + mainAxisSize: MainAxisSize.min, + children: [ + /// Labels/Tags + if (tags.isNotEmpty) ...{ + _SearchCategory( + icon: StyledIcons.label, + text: "Labels", + child: Wrap( + spacing: Insets.m, + runSpacing: Insets.sm, + children: labelPills, + ), + ), + }, + /// Contacts / People + if (contacts.isNotEmpty) ...{ + _SearchCategory( + icon: StyledIcons.user, + text: "Contacts", + child: SimpleGrid( + colCount: colCount, + hSpace: Insets.xl, + vSpace: Insets.sm, + kidHeight: 48, + kids: contactListItems, + ), + ), + }, + /// Submit Btn + if (contacts.length > 6) ...{ + TransparentTextBtn( + "Show More (${contacts.length - 6} results)", + bgColor: theme.surface, + bigMode: true, + onPressed: state.handleSearchSubmitted, + ).constrained(width: 220, height: 60).center().padding(top: Insets.l), + VSpace(Insets.m * 1.5), + }, + ], + ), + ); + }, + ); + }, + ); + } +} + +class _ContactSearchListItem extends StatelessWidget { + final ContactData contact; + final VoidCallback onPressed; + + _ContactSearchListItem({this.contact, this.onPressed}); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return TransparentBtn( + bgColor: theme.surface, + onPressed: onPressed, + bigMode: true, + child: Container( + child: Row( + children: [ + StyledUserAvatar(contact: contact, size: 36), + HSpace(Insets.m * 1.5), + Text(contact.nameFull, style: TextStyles.H2.textColor(theme.txt)), + ], + ), + ), + ); + } +} + +class _SearchCategory extends StatelessWidget { + final AssetImage icon; + final String text; + final Widget child; + + _SearchCategory({@required this.icon, @required this.text, @required this.child}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [_SearchHeading(icon, text), child.padding(horizontal: Insets.xl, top: Insets.m)], + ).padding(horizontal: Insets.xl, top: Insets.l * 0.9); + } +} + +class _SearchHeading extends StatelessWidget { + final AssetImage icon; + final String text; + + _SearchHeading(this.icon, this.text); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + TextStyle txtStyle = TextStyles.T1.size(FontSizes.s16).letterSpace(0.8).textColor(theme.accent1Darker); + return Row(children: [ + StyledImageIcon(icon, color: theme.grey), + HSpace(Insets.m * 1.5), + Text(text.toUpperCase(), style: txtStyle), + ]); + } +} diff --git a/flokk_src/lib/views/search/search_query_row.dart b/flokk_src/lib/views/search/search_query_row.dart new file mode 100644 index 0000000..081df58 --- /dev/null +++ b/flokk_src/lib/views/search/search_query_row.dart @@ -0,0 +1,133 @@ +import 'dart:math'; + +import 'package:flokk/_internal/components/listenable_builder.dart'; +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/_internal/utils/string_utils.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/scrolling/styled_horizontal_scroll_view.dart'; +import 'package:flokk/styled_components/styled_group_label.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_image_icon.dart'; +import 'package:flokk/styled_components/styled_text_input.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/search/search_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SearchQueryRow extends StatelessWidget { + final SearchBarState state; + + const SearchQueryRow(this.state, {Key key}) : super(key: key); + + double calcTagWidth(String tag) { + //Calculate all padding in the row (searchIcon + padding + closeIcon + padding) + double tagPadding = 30 + Insets.m + 24 + Insets.m; + //Return size of text + padding + return StringUtils.measure(tag.toUpperCase(), TextStyles.Footnote.letterSpace(0)).width + tagPadding; + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return LayoutBuilder( + builder: (_, constraints) { + //Bind to search engine for results + return ListenableBuilder( + listenable: state.tmpSearch, + builder: (_, __) { + // Calculate the width of the text input based off of the width of the search bar + double contentWidth = constraints.maxWidth - Insets.l + Insets.m; + // Remove size of the close and search icons (in mobile mode, they are combined) + double barQueryWidth = max(0, contentWidth - (Sizes.iconMed + Insets.l)); + if (state.widget.narrowMode == false) { + barQueryWidth -= (Sizes.iconMed + Insets.m * 1.5); + } + //Subtract widths of tags and contacts, to get the max size for text input + double barTextFieldWidth = barQueryWidth; + state.tmpSearch.tagList.forEach((t) => barTextFieldWidth -= calcTagWidth(t)); + state.tmpSearch.filterContactList.forEach((fc) => barTextFieldWidth -= calcTagWidth(fc)); + //Enforce min-size of 200px for search input + barTextFieldWidth = max(200, barTextFieldWidth); + + return Row( + children: [ + HSpace(Insets.l), + if (state.widget.narrowMode == false) ...{ + _SearchIconBtn(state.handleSearchIconPressed), + HSpace(Insets.m), + }, + ConstrainedBox( + constraints: BoxConstraints(maxWidth: barQueryWidth), + child: StyledHorizontalScrollView( + autoScrollDuration: Durations.fast, + autoScrollCurve: Curves.easeOut, + child: Row( + children: [ + for (var tag in state.tmpSearch.tagList) ...{ + StyledGroupLabel(icon: StyledIcons.label, text: tag, onClose: () => state.handleRemoveTag(tag)) + .padding(right: Insets.m), + }, + for (var filterContact in state.tmpSearch.filterContactList) ...{ + StyledGroupLabel( + icon: StyledIcons.user, + text: filterContact, + onClose: () => state.handleRemoveFilterContact(filterContact)).padding(right: Insets.m), + }, + Container( + constraints: BoxConstraints(maxWidth: barTextFieldWidth), + child: StyledSearchTextInput( + contentPadding: EdgeInsets.all(Insets.m * 1.25 - 0.5).copyWith(left: 0), + hintText: state.widget.narrowMode ? "" : "Search for contacts", + key: state.textKey, + onChanged: state.handleSearchChanged, + // Disabled because this callback has different behavior on web for some reason, + // the hook is now in the states _handleRawKeyPressed method + //onFieldSubmitted: (s) => state.handleSearchSubmitted(), + onEditingCancel: state.cancel, + onFocusChanged: state.handleFocusChanged, + onFocusCreated: state.handleTextFocusCreated, + ), + ), + ], + ), + ), + ), + if (state.hasQuery) ...{ + ColorShiftIconBtn( + StyledIcons.closeLarge, + padding: EdgeInsets.zero, + size: 16, + minHeight: 0, + minWidth: 0, + color: theme.grey, + onPressed: state.clearSearch, + ), + } else if (state.widget.narrowMode) ...{ + _SearchIconBtn(state.handleSearchIconPressed), + }, + HSpace(Insets.m), + ], + ); + }); + }, + ); + } +} + +class _SearchIconBtn extends StatelessWidget { + final void Function() onPressed; + + const _SearchIconBtn(this.onPressed, {Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + return StyledImageIcon( + StyledIcons.search, + size: Sizes.iconMed, + color: theme.accent1Darker, + ).clickable(onPressed, opaque: true); + } +} diff --git a/flokk_src/lib/views/welcome/animated_bird_splash.dart b/flokk_src/lib/views/welcome/animated_bird_splash.dart new file mode 100644 index 0000000..9b9afc0 --- /dev/null +++ b/flokk_src/lib/views/welcome/animated_bird_splash.dart @@ -0,0 +1,108 @@ +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/flokk_logo.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/welcome/animated_bird_splash_clipper.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class AnimatedBirdSplashWidget extends StatefulWidget { + final Alignment alignment; + final bool showText; + final bool showLogo; + + const AnimatedBirdSplashWidget({Key key, this.alignment, this.showText = false, this.showLogo = true}) + : super(key: key); + + @override + _AnimatedBirdSplashState createState() => _AnimatedBirdSplashState(); +} + +class _AnimatedBirdSplashState extends State with SingleTickerProviderStateMixin { + GooeyEdge _gooeyEdge; + + AnimationController _animationController; + double _cloudXOffset = 0.0; + + @override + void initState() { + _gooeyEdge = GooeyEdge(); + _animationController = AnimationController(vsync: this); + _animationController.repeat(reverse: true, min: 0.0, max: 1.0, period: 800.milliseconds); + _animationController.addListener(_tick); + super.initState(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _tick() { + _gooeyEdge.tick(_animationController.lastElapsedDuration); + _cloudXOffset += _animationController.velocity * 0.08; + while (_cloudXOffset > 800.0) { + _cloudXOffset -= 800.0; + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + String bgImagePath = "assets/images/onboarding-bg.png"; + String cloudImagePath = "assets/images/onboarding-clouds.png"; + String fgImagePath = "assets/images/onboarding-birds.png"; + return Stack( + children: [ + Stack(children: [ + /// Clipped Image Stack + ClipPath( + clipper: AnimatedBirdSplashClipper(_gooeyEdge), + child: Stack(children: [ + /// BG + _BuildImage(bgImagePath, BoxFit.fill) + .positioned(left: 0, top: 0, right: 0, bottom: 0), + + /// CLOUD 1 + _BuildImage(cloudImagePath) + .translate(offset: Offset(_cloudXOffset, 0)) + .fractionallySizedBox(heightFactor: 0.4), + + /// CLOUD2 + _BuildImage(cloudImagePath) + .translate(offset: Offset(-800 + _cloudXOffset, 0)) + .fractionallySizedBox(heightFactor: 0.4), + + /// Foreground + _BuildImage(fgImagePath, BoxFit.scaleDown).center(), + ]), + ).aspectRatio(aspectRatio: 1.8).constrained(maxWidth: 700), + + /// Loading Text + Text( + "GATHERING YOUR FLOKK...", + style: TextStyles.T1.textColor(theme.accent1Darker), + textAlign: TextAlign.center, + ) //Bottom positioned, fades in and out + .alignment(Alignment.bottomCenter) + .translate(offset: Offset(0, 46)) // Offset text below the bottom edge of the images + .opacity(widget.showText ? 1 : 0, animate: true) + .animate(Durations.slow, Curves.easeOut) + .positioned(left: 0, top: 0, right: 0, bottom: 0) + ]).center(), + + /// Flock Logo + if (widget.showLogo) + FlokkLogo(56, Color(0xff116d5a)) + .center() + .constrained(width: 156, height: 56) + .alignment(Alignment(-0.84, -0.84)), + ], + ); + } + + Widget _BuildImage(String url, [BoxFit fit = BoxFit.fitHeight]) => + Image.asset(url, filterQuality: FilterQuality.high, fit: fit); +} diff --git a/flokk_src/lib/views/welcome/animated_bird_splash_clipper.dart b/flokk_src/lib/views/welcome/animated_bird_splash_clipper.dart new file mode 100644 index 0000000..d2f08e0 --- /dev/null +++ b/flokk_src/lib/views/welcome/animated_bird_splash_clipper.dart @@ -0,0 +1,124 @@ + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:rnd/rnd.dart'; + +class AnimatedBirdSplashClipper extends CustomClipper { + final GooeyEdge gooeyEdge; + + AnimatedBirdSplashClipper(this.gooeyEdge); + + @override + Path getClip(Size size) { + return gooeyEdge.buildPath(size); + } + + @override + bool shouldReclip(AnimatedBirdSplashClipper oldClipper) { + return true; + } +} + + +class GooeyEdge { + List<_Point> points; + double damping = 0.85; + double tension = 0.005; + double roundness = 0.6; + int count; + double edgeFactor; + int lastT = 0; + + // TODO: initValues? + GooeyEdge({this.count=8, this.edgeFactor=0.09}) { + points = []; + int ttl = count * 4 - 2; + for (int i=0; i WelcomePageState(); +} + +/// WelcomePage will hold the state for the sub-views, this primarily to easily avoid any issues +/// with state of [WelcomePageStep2] being lost when we re-arrange the widget tree +class WelcomePageState extends State { + GoogleRestService googleRest; + GoogleAuthEndpointInfo authInfo; + String authUrl = "https://google.com"; + String authCode = "10001"; + bool httpError = false; + bool authCodeError = false; + int pageIndex = 0; + + bool get isLoading => _isLoading; + bool _isLoading = false; + + set isLoading(bool value) => setState(() => _isLoading = value); + + Size prevSize; + bool showContent; + bool twoColumnMode = true; + + @override + void initState() { + showContent = widget.initialPanelOpen; + googleRest = GoogleRestService(); + loadAuthInfo(); + + //Need to call signInSilently() in initState() to prevent FF from showing popup alert + if (UniversalPlatform.isWeb) { + final gs = GoogleSignIn( + clientId: ApiKeys().googleWebClientId, + scopes: ['https://www.googleapis.com/auth/contacts'], + ); + gs.signInSilently(); + } + super.initState(); + } + + //TODO: This is currently firing every time the app loads, should only fire when they hit the btn, and only on desktop + Future loadAuthInfo() async { + httpError = false; + authCodeError = false; + ServiceResult result = await googleRest.auth.getAuthEndpoint(); + authInfo = result.content; + if (authInfo != null) { + authCode = authInfo.userCode; + authUrl = authInfo.verificationUrl; + } else { + httpError = true; + } + isLoading = false; + } + + /// Allows someone else to tell us to open the panel + void showPanel(value) => setState(() => showContent = value); + + void refreshDataAndLoadApp() async { + /// Load initial contacts + isLoading = true; + await RefreshContactsCommand(context).execute(); + await RefreshSocialCommand(context).execute(context.read().allContacts); + + /// Show main app view + Navigator.push(context, PageRoutes.fade(() => MainScaffold(), Durations.slow.inMilliseconds * .001)); + } + + void handleUrlClicked() => UrlLauncher.open(authUrl); + + void handleCodeClicked() => Clipboard.setData(ClipboardData(text: authCode)); + + void handleRefreshPressed() { + setState(() => _isLoading = true); + loadAuthInfo(); + } + + void handleStartPressed() async { + if (UniversalPlatform.isWeb) { + bool success = await WebSignInCommand(context).execute(); + // We're in :) Load main app + if (success) refreshDataAndLoadApp(); + } else { + setState(() => pageIndex = 1); + } + } + + void handleCompletePressed() async { + if (httpError) { + Dialogs.show(OkCancelDialog( + message: "We are unable to authorize with Google's servers. " + "Check your internet connection and try again.", + )); + return; + } + isLoading = true; + authCodeError = false; + await Future.delayed(Duration(milliseconds: 500)); + ServiceResult result = await googleRest.auth.authorizeDevice(authInfo.deviceCode); + GoogleAuthResults authResults = result.content; + if (authResults != null) { + //We have a token! Update the model. + AuthModel model = Provider.of(context, listen: false); + model.googleEmail = authResults.email; + model.googleAccessToken = authResults.accessToken; + model.googleRefreshToken = authResults.refreshToken; + model.setExpiry(authResults.expiresIn); + model.scheduleSave(); + // Hide panel since we know we're basically logged in now... + setState(() => showContent = false); + // Load main app + refreshDataAndLoadApp(); + } else { + authCodeError = true; + isLoading = false; + } + } + + @override + Widget build(BuildContext context) { + /// Provide this ViewModel/State to the sub-views, so they can easily call fxns or lookup state + return Provider.value(value: this, child: _WelcomePageStateView()); + } +} + +class _WelcomePageStateView extends StatelessWidget { + @override + Widget build(BuildContext context) { + WelcomePageState state = context.watch(); + //Check a breakpoint to see whether we want side:side view or full screen + double columnBreakPt = PageBreaks.TabletLandscape - 100; + state.twoColumnMode = context.widthPx > columnBreakPt; + // Calculate how wide we want the panel, add some extra width as it grows + double contentWidth = state.twoColumnMode ? 300 : double.infinity; + if (state.twoColumnMode) { + // For every 100px > the PageBreak add some panel width. Cap at some max width. + double maxWidth = 700; + contentWidth += min(maxWidth, context.widthPx * .15); + } + // Looks janky if Birds animate when resizing window + // disable animations if we're rebuilding because of resize + bool skipBirdTransition = false; + if (state.prevSize != context.sizePx) skipBirdTransition = true; + state.prevSize = context.sizePx; + + return Scaffold( + backgroundColor: Colors.white, + body: TweenAnimationBuilder( + duration: Durations.slow, + tween: Tween(begin: 0, end: 1), + builder: (_, value, ___) => Opacity( + opacity: value, + child: Center( + child: Stack( + fit: StackFit.expand, + children: [ + Container( + alignment: Alignment.center, + child: AnimatedBirdSplashWidget( + showText: state.isLoading, + ), + ) + .opacity(1.0) + .padding(right: (state.showContent && state.twoColumnMode ? contentWidth : 0), animate: true) + .animate( + skipBirdTransition ? 0.seconds : Durations.slow, + Curves.easeOut, + ), + _WelcomeContentStack() + .width(contentWidth) + // Use an AnimatedPanel to slide the panel open/closed + .animatedPanelX( + isClosed: !state.showContent, + closeX: context.widthPx, + curve: Curves.easeOut, + duration: Durations.slow.inMilliseconds * .001, + ) + // Pin the left side on fullscreen, respect existing width otherwise + .positioned(top: 0, bottom: 0, right: 0, left: state.twoColumnMode ? null : 0) + ], + ), + ), + ), + )); + } +} + +/// Holds the 2 WelcomePages and an IndexedStack to switch between them +class _WelcomeContentStack extends StatelessWidget { + const _WelcomeContentStack({Key key}) : super(key: key); + + void _handlePrivacyPolicyPressed(String value) { + UrlLauncher.openHttp("https://flokk.app/privacy.html"); + } + + @override + Widget build(BuildContext context) { + WelcomePageState state = context.watch(); + //Bg shape is rounded on the left corners when in dual-column mode, but square in full-screen + BorderRadius getBgShape() => state.twoColumnMode + ? BorderRadius.only(topLeft: Radius.circular(Corners.s10), bottomLeft: Radius.circular(Corners.s10)) + : null; + + AppTheme theme = context.watch(); + return state.isLoading + ? StyledProgressSpinner().backgroundColor(theme.accent1) + : Stack( + children: [ + FadingIndexedStack( + duration: Durations.slow, + index: state.pageIndex, + children: [ + WelcomePageStep1(singleColumnMode: !state.twoColumnMode).scrollable().center(), + WelcomePageStep2().scrollable().center(), + ], + ).padding(vertical: Insets.l * 1.5).center(), + ClickableText( + "Privacy Policy", + linkColor: Colors.white, + underline: true, + onPressed: _handlePrivacyPolicyPressed, + ).padding(bottom: Insets.m).alignment(Alignment.bottomCenter), + ], + ) + .padding(horizontal: Insets.l) + .decorated(color: theme.accent1, borderRadius: getBgShape()) + .alignment(Alignment.center) + .width(double.infinity); + } +} diff --git a/flokk_src/lib/views/welcome/welcome_page_step1.dart b/flokk_src/lib/views/welcome/welcome_page_step1.dart new file mode 100644 index 0000000..c000d62 --- /dev/null +++ b/flokk_src/lib/views/welcome/welcome_page_step1.dart @@ -0,0 +1,57 @@ +import 'package:flokk/_internal/components/seperated_flexibles.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/primary_btn.dart'; +import 'package:flokk/styled_components/flokk_logo.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/views/welcome/animated_bird_splash.dart'; +import 'package:flokk/views/welcome/welcome_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class WelcomePageStep1 extends StatelessWidget { + final bool singleColumnMode; + + const WelcomePageStep1({Key key, this.singleColumnMode = false}) : super(key: key); + + @override + Widget build(BuildContext context) { + WelcomePageState state = context.watch(); + TextStyle bodyTxtStyle = TextStyles.Body1.textColor(Color(0xfff1f7f0)).textHeight(1.6); + return SeparatedColumn( + separatorBuilder: () => SizedBox(height: Insets.l), + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (singleColumnMode) FlokkLogo(50, Colors.white).center(), + if (singleColumnMode) + AnimatedBirdSplashWidget(alignment: Alignment.bottomCenter, showLogo: false) + .padding(all: Insets.m * 1.5) + .height(context.heightPx * .4), + [ + Text( + "Welcome to Flokk Contacts", + style: TextStyles.CalloutFocus.bold.size(24).textColor(Colors.white), + textAlign: TextAlign.center, + ), + Text( + "Flokk is a modern Google Contacts manager that integrates with your connections on Twitter and GitHub.", + style: bodyTxtStyle, + textAlign: TextAlign.center, + ).padding(vertical: Insets.l), + Text( + "To get started, you will first need to authorize this application and import your existing Google Contacts.", + style: bodyTxtStyle, + textAlign: TextAlign.center, + ), + ].toColumn().constrained(maxWidth: 400), + kIsWeb + ? Image.asset("assets/images/google-signin.png", height: 50).gestures(onTap: state.handleStartPressed) + : PrimaryTextBtn( + "LET'S START!", + bigMode: true, + onPressed: state.handleStartPressed, + ).padding(top: Insets.m).width(239), + ], + ).padding(vertical: Insets.l); + } +} diff --git a/flokk_src/lib/views/welcome/welcome_page_step2.dart b/flokk_src/lib/views/welcome/welcome_page_step2.dart new file mode 100644 index 0000000..12f87b7 --- /dev/null +++ b/flokk_src/lib/views/welcome/welcome_page_step2.dart @@ -0,0 +1,153 @@ +import 'package:flokk/_internal/components/spacing.dart'; +import 'package:flokk/app_extensions.dart'; +import 'package:flokk/styled_components/buttons/colored_icon_btn.dart'; +import 'package:flokk/styled_components/buttons/primary_btn.dart'; +import 'package:flokk/styled_components/styled_icons.dart'; +import 'package:flokk/styled_components/styled_progress_spinner.dart'; +import 'package:flokk/styles.dart'; +import 'package:flokk/themes.dart'; +import 'package:flokk/views/welcome/welcome_page.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class WelcomePageStep2 extends StatefulWidget { + const WelcomePageStep2({Key key}) : super(key: key); + + @override + _WelcomePageStep2State createState() => _WelcomePageStep2State(); +} + +class _WelcomePageStep2State extends State { + @override + Widget build(BuildContext context) { + AppTheme theme = context.watch(); + WelcomePageState state = context.watch(); + + TextStyle bodyTxtStyle = TextStyles.Body1.textColor(Colors.white).textHeight(1.6); + TextStyle titleTxtStyle = TextStyles.T1.textColor(theme.accent1Darker); + TextStyle headerTxtStyle = TextStyles.CalloutFocus.bold.size(24).textColor(Colors.white); + + return state.isLoading + ? StyledProgressSpinner() + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Authorization", style: headerTxtStyle, textAlign: TextAlign.center), + VSpace(Insets.l * 2), + + /// //////////////////////////////////////////////// + /// STEP 1 + Text("STEP 1", style: titleTxtStyle, textAlign: TextAlign.center), + Text( + "Copy the following code to your clipboard by clicking the icon or selecting the text.", + style: bodyTxtStyle, + textAlign: TextAlign.center, + ), + + /// DEVICE CODE BOX + StyledOutlinedBox( + child: state.isLoading + ? StyledProgressSpinner() + : Stack( + fit: StackFit.expand, + children: [ + SelectableText("${state.authCode}", style: bodyTxtStyle.size(16)).center(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ColorShiftIconBtn(StyledIcons.refresh, + size: 28, color: Colors.white, onPressed: state.handleRefreshPressed), + ColorShiftIconBtn(StyledIcons.copy, + size: 24, color: Colors.white, onPressed: state.handleCodeClicked), + ], + ).padding(horizontal: Insets.m), + ], + ), + ).padding(vertical: Insets.l), + VSpace(Insets.m), + + /// //////////////////////////////////////////////// + /// STEP 2 + Text("STEP 2", style: titleTxtStyle, textAlign: TextAlign.center), + Text( + "Navigate to the following link and enter the code you’ve copied.\nFollow the provided instructions to authorize this application.", + style: bodyTxtStyle, + textAlign: TextAlign.center, + ), + + /// DEVICE URL BOX + StyledOutlinedBox( + child: state.isLoading + ? StyledProgressSpinner() + : Stack( + fit: StackFit.expand, + children: [ + SelectableText("${state.authUrl}", style: bodyTxtStyle.size(16)).center(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ColorShiftIconBtn(StyledIcons.linkout, + size: 24, color: Colors.white, onPressed: state.handleUrlClicked), + ], + ).padding(horizontal: Insets.m), + ], + ), + ).padding(vertical: Insets.l), + VSpace(Insets.m), + + /// //////////////////////////////////////////////// + /// STEP 3 + Text("STEP 3", style: titleTxtStyle, textAlign: TextAlign.center), + Text( + "Press the button below when you have completed the process.", + style: bodyTxtStyle, + textAlign: TextAlign.center, + ), + VSpace(Insets.m), + PrimaryTextBtn( + "CONTINUE", + bigMode: true, + onPressed: state.handleCompletePressed, + ).padding(top: Insets.m).width(215), + VSpace(Insets.l), + if (state.authCodeError || state.httpError) + Text( + "Error: ${getCurrentErrorCode(state)}", + textAlign: TextAlign.center, + style: TextStyles.T1.bold.textColor( + theme.error, + ), + ).padding(all: Insets.m).decorated( + color: Colors.white, + borderRadius: BorderRadius.circular(Corners.s5), + ), + ], + ).constrained(maxWidth: 500); + } + + String getCurrentErrorCode(WelcomePageState state) { + if (state.httpError) { + return "We couldn’t connect to the internet. Please check your connection."; + } else { + return "We couldn’t authorize your account. Please make sure that you’ve completed the entire verification process."; + } + } +} + +class StyledOutlinedBox extends StatelessWidget { + final Widget child; + + const StyledOutlinedBox({Key key, this.child}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 60, + decoration: BoxDecoration( + borderRadius: Corners.s8Border, + border: Border.all(color: Colors.white.withOpacity(.35), width: 1.2), + ), + child: child, + ); + } +} diff --git a/flokk_src/linux/.gitignore b/flokk_src/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/flokk_src/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/flokk_src/linux/CMakeLists.txt b/flokk_src/linux/CMakeLists.txt new file mode 100644 index 0000000..6f28e6a --- /dev/null +++ b/flokk_src/linux/CMakeLists.txt @@ -0,0 +1,96 @@ +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +set(BINARY_NAME "flokk-contacts") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "window_configuration.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) +apply_standard_settings(${BINARY_NAME}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/flokk_src/linux/flutter/.template_version b/flokk_src/linux/flutter/.template_version new file mode 100644 index 0000000..b8626c4 --- /dev/null +++ b/flokk_src/linux/flutter/.template_version @@ -0,0 +1 @@ +4 diff --git a/flokk_src/linux/flutter/CMakeLists.txt b/flokk_src/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..17576f6 --- /dev/null +++ b/flokk_src/linux/flutter/CMakeLists.txt @@ -0,0 +1,86 @@ +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + linux-x64 ${CMAKE_BUILD_TYPE} +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/flokk_src/linux/flutter/generated_plugin_registrant.h b/flokk_src/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..9bf7478 --- /dev/null +++ b/flokk_src/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,13 @@ +// +// Generated file. Do not edit. +// + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/flokk_src/linux/flutter/generated_plugins.cmake b/flokk_src/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..cb9b998 --- /dev/null +++ b/flokk_src/linux/flutter/generated_plugins.cmake @@ -0,0 +1,17 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_chooser + window_size +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter\ephemeral\.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) diff --git a/flokk_src/linux/main.cc b/flokk_src/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/flokk_src/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/flokk_src/linux/my_application.cc b/flokk_src/linux/my_application.cc new file mode 100644 index 0000000..eb36c6d --- /dev/null +++ b/flokk_src/linux/my_application.cc @@ -0,0 +1,42 @@ +#include "my_application.h" + +#include + +#include "flutter/generated_plugin_registrant.h" +#include "window_configuration.h" + +struct _MyApplication { + GtkApplication parent_instance; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + gtk_widget_show(GTK_WIDGET(window)); + gtk_widget_set_size_request(GTK_WIDGET(window), kFlutterWindowWidth, + kFlutterWindowHeight); + gtk_window_set_title(window, kFlutterWindowTitle); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), nullptr)); +} diff --git a/flokk_src/linux/my_application.h b/flokk_src/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/flokk_src/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/flokk_src/linux/window_configuration.cc b/flokk_src/linux/window_configuration.cc new file mode 100644 index 0000000..39b1add --- /dev/null +++ b/flokk_src/linux/window_configuration.cc @@ -0,0 +1,5 @@ +#include "window_configuration.h" + +const char *kFlutterWindowTitle = "Flokk Contacts"; +const unsigned int kFlutterWindowWidth = 1366; +const unsigned int kFlutterWindowHeight = 768; diff --git a/flokk_src/linux/window_configuration.h b/flokk_src/linux/window_configuration.h new file mode 100644 index 0000000..8592c5b --- /dev/null +++ b/flokk_src/linux/window_configuration.h @@ -0,0 +1,15 @@ +#ifndef WINDOW_CONFIGURATION_ +#define WINDOW_CONFIGURATION_ + +// This is a temporary approach to isolate common customizations from main.cc, +// where the APIs are still in flux. This should simplify re-creating the +// runner while preserving local changes. +// +// Longer term there should be simpler configuration options for common +// customizations like this, without requiring native code changes. + +extern const char *kFlutterWindowTitle; +extern const unsigned int kFlutterWindowWidth; +extern const unsigned int kFlutterWindowHeight; + +#endif // WINDOW_CONFIGURATION_ diff --git a/flokk_src/macos/.gitignore b/flokk_src/macos/.gitignore new file mode 100644 index 0000000..d2fd377 --- /dev/null +++ b/flokk_src/macos/.gitignore @@ -0,0 +1,6 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/xcuserdata/ diff --git a/flokk_src/macos/Flutter/Flutter-Debug.xcconfig b/flokk_src/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..785633d --- /dev/null +++ b/flokk_src/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flokk_src/macos/Flutter/Flutter-Release.xcconfig b/flokk_src/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5fba960 --- /dev/null +++ b/flokk_src/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/flokk_src/macos/Flutter/GeneratedPluginRegistrant.swift b/flokk_src/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..af52176 --- /dev/null +++ b/flokk_src/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_chooser +import window_size +import path_provider_macos +import shared_preferences_macos +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileChooserPlugin.register(with: registry.registrar(forPlugin: "FileChooserPlugin")) + WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/flokk_src/macos/Podfile b/flokk_src/macos/Podfile new file mode 100644 index 0000000..d60ec71 --- /dev/null +++ b/flokk_src/macos/Podfile @@ -0,0 +1,82 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def parse_KV_file(file, separator='=') + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return []; + end + pods_ary = [] + skip_line_start_symbols = ["#", "/"] + File.foreach(file_abs_path) { |line| + next if skip_line_start_symbols.any? { |symbol| line =~ /^\s*#{symbol}/ } + plugin = line.split(pattern=separator) + if plugin.length == 2 + podname = plugin[0].strip() + path = plugin[1].strip() + podpath = File.expand_path("#{path}", file_abs_path) + pods_ary.push({:name => podname, :path => podpath}); + else + puts "Invalid plugin specification: #{line}" + end + } + return pods_ary +end + +def pubspec_supports_macos(file) + file_abs_path = File.expand_path(file) + if !File.exists? file_abs_path + return false; + end + File.foreach(file_abs_path) { |line| + return true if line =~ /^\s*macos:/ + } + return false +end + +target 'Runner' do + use_frameworks! + use_modular_headers! + + # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock + # referring to absolute paths on developers' machines. + ephemeral_dir = File.join('Flutter', 'ephemeral') + symlink_dir = File.join(ephemeral_dir, '.symlinks') + symlink_plugins_dir = File.join(symlink_dir, 'plugins') + system("rm -rf #{symlink_dir}") + system("mkdir -p #{symlink_plugins_dir}") + + # Flutter Pods + generated_xcconfig = parse_KV_file(File.join(ephemeral_dir, 'Flutter-Generated.xcconfig')) + if generated_xcconfig.empty? + puts "Flutter-Generated.xcconfig must exist. If you're running pod install manually, make sure flutter packages get is executed first." + end + generated_xcconfig.map { |p| + if p[:name] == 'FLUTTER_FRAMEWORK_DIR' + symlink = File.join(symlink_dir, 'flutter') + File.symlink(File.dirname(p[:path]), symlink) + pod 'FlutterMacOS', :path => File.join(symlink, File.basename(p[:path])) + end + } + + # Plugin Pods + plugin_pods = parse_KV_file('../.flutter-plugins') + plugin_pods.map { |p| + symlink = File.join(symlink_plugins_dir, p[:name]) + File.symlink(p[:path], symlink) + if pubspec_supports_macos(File.join(symlink, 'pubspec.yaml')) + pod p[:name], :path => File.join(symlink, 'macos') + end + } +end + +# Prevent Cocoapods from embedding a second Flutter framework and causing an error with the new Xcode build system. +install! 'cocoapods', :disable_input_output_paths => true diff --git a/flokk_src/macos/Podfile.lock b/flokk_src/macos/Podfile.lock new file mode 100644 index 0000000..9b2e864 --- /dev/null +++ b/flokk_src/macos/Podfile.lock @@ -0,0 +1,56 @@ +PODS: + - file_chooser (0.0.2): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - path_provider_macos (0.0.1): + - FlutterMacOS + - shared_preferences (0.0.1) + - shared_preferences_macos (0.0.1): + - FlutterMacOS + - url_launcher (0.0.1) + - url_launcher_macos (0.0.1): + - FlutterMacOS + - window_size (0.0.2): + - FlutterMacOS + +DEPENDENCIES: + - file_chooser (from `Flutter/ephemeral/.symlinks/plugins/file_chooser/macos`) + - FlutterMacOS (from `Flutter/ephemeral/.symlinks/flutter/darwin-x64-release`) + - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) + - shared_preferences (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences/macos`) + - shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`) + - url_launcher (from `Flutter/ephemeral/.symlinks/plugins/url_launcher/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) + +EXTERNAL SOURCES: + file_chooser: + :path: Flutter/ephemeral/.symlinks/plugins/file_chooser/macos + FlutterMacOS: + :path: Flutter/ephemeral/.symlinks/flutter/darwin-x64-release + path_provider_macos: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos + shared_preferences: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences/macos + shared_preferences_macos: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos + url_launcher: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + window_size: + :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos + +SPEC CHECKSUMS: + file_chooser: 24432cf5dc836722b05c11c2a0a30d19c3c9b996 + FlutterMacOS: 15bea8a44d2fa024068daa0140371c020b4b6ff9 + path_provider_macos: adb94ae6c253c45ef2aac146fbf1f4504d74b0f8 + shared_preferences: 9fec34d1bd906196a4da48fcf6c3ad521cc00b8d + shared_preferences_macos: 5e5c2839894accb56b7d23328905b757f2bafaf6 + url_launcher: af78307ef9bafff91273b34f1c6c0c86a0004fd7 + url_launcher_macos: 76867a28e24e0b6b98bfd65f157b64108e6d477a + window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 + +PODFILE CHECKSUM: d8ba9b3e9e93c62c74a660b46c6fcb09f03991a7 + +COCOAPODS: 1.9.1 diff --git a/flokk_src/macos/Runner.xcodeproj/project.pbxproj b/flokk_src/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..3e76a3f --- /dev/null +++ b/flokk_src/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,653 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; }; + 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 33ED57F6868520F1A5AD7E05 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DBE9A05F81536E7CC1EF874F /* Pods_Runner.framework */; }; + D73912F022F37F9E000D13A0 /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; }; + D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */ = {isa = PBXBuildFile; fileRef = D73912EF22F37F9E000D13A0 /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + D73912F222F3801D000D13A0 /* App.framework in Bundle Framework */, + 33D1A10522148B93006C7A3E /* FlutterMacOS.framework in Bundle Framework */, + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 06B92AD24BA9A7C825B4F767 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* Flokk Contacts.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Flokk Contacts.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = FlutterMacOS.framework; path = Flutter/ephemeral/FlutterMacOS.framework; sourceTree = SOURCE_ROOT; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9870544E3D46909BAC6B2388 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + CFAD0631A399198584780275 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + D73912EF22F37F9E000D13A0 /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/ephemeral/App.framework; sourceTree = SOURCE_ROOT; }; + DBE9A05F81536E7CC1EF874F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D73912F022F37F9E000D13A0 /* App.framework in Frameworks */, + 33D1A10422148B71006C7A3E /* FlutterMacOS.framework in Frameworks */, + 33ED57F6868520F1A5AD7E05 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 6C30B9EDB93BB4AE28985C5A /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* Flokk Contacts.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + D73912EF22F37F9E000D13A0 /* App.framework */, + 33D1A10322148B71006C7A3E /* FlutterMacOS.framework */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 6C30B9EDB93BB4AE28985C5A /* Pods */ = { + isa = PBXGroup; + children = ( + 9870544E3D46909BAC6B2388 /* Pods-Runner.debug.xcconfig */, + CFAD0631A399198584780275 /* Pods-Runner.release.xcconfig */, + 06B92AD24BA9A7C825B4F767 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + DBE9A05F81536E7CC1EF874F /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + AB2ED158C69CFA4199E04879 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 4E390F38A0B912A52BC0570E /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* Flokk Contacts.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0930; + ORGANIZATIONNAME = "The Flutter Authors"; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh\ntouch Flutter/ephemeral/tripwire\n"; + }; + 4E390F38A0B912A52BC0570E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + AB2ED158C69CFA4199E04879 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter/ephemeral", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/flokk_src/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flokk_src/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flokk_src/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flokk_src/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flokk_src/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c4c7630 --- /dev/null +++ b/flokk_src/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flokk_src/macos/Runner.xcworkspace/contents.xcworkspacedata b/flokk_src/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/flokk_src/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/flokk_src/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flokk_src/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flokk_src/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flokk_src/macos/Runner/AppDelegate.swift b/flokk_src/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..d53ef64 --- /dev/null +++ b/flokk_src/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..34ccffe Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..e75be2c Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..9bb69d8 Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..eccaf7d Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..2fdf4b2 Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..6c27295 Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..1d4cb0a Binary files /dev/null and b/flokk_src/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/flokk_src/macos/Runner/Base.lproj/MainMenu.xib b/flokk_src/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..537341a --- /dev/null +++ b/flokk_src/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flokk_src/macos/Runner/Configs/AppInfo.xcconfig b/flokk_src/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..5f3557c --- /dev/null +++ b/flokk_src/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = Flokk Contacts + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = app.flokk + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2020 com.gskinner. All rights reserved. diff --git a/flokk_src/macos/Runner/Configs/Debug.xcconfig b/flokk_src/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/flokk_src/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/flokk_src/macos/Runner/Configs/Release.xcconfig b/flokk_src/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/flokk_src/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/flokk_src/macos/Runner/Configs/Warnings.xcconfig b/flokk_src/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/flokk_src/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/flokk_src/macos/Runner/DebugProfile.entitlements b/flokk_src/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..8165abf --- /dev/null +++ b/flokk_src/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/flokk_src/macos/Runner/Info.plist b/flokk_src/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/flokk_src/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/flokk_src/macos/Runner/MainFlutterWindow.swift b/flokk_src/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..2722837 --- /dev/null +++ b/flokk_src/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/flokk_src/macos/Runner/Release.entitlements b/flokk_src/macos/Runner/Release.entitlements new file mode 100644 index 0000000..741903e --- /dev/null +++ b/flokk_src/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.files.user-selected.read-only + + + diff --git a/flokk_src/pubspec.lock b/flokk_src/pubspec.lock new file mode 100644 index 0000000..640a3c7 --- /dev/null +++ b/flokk_src/pubspec.lock @@ -0,0 +1,664 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _discoveryapis_commons: + dependency: transitive + description: + name: _discoveryapis_commons + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.9" + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + url: "https://pub.dartlang.org" + source: hosted + version: "0.39.10" + animations: + dependency: "direct main" + description: + name: animations + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0+5" + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.1" + build: + dependency: transitive + description: + name: build + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + build_config: + dependency: transitive + description: + name: build_config + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.3" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.12" + convert: + dependency: transitive + description: + name: convert + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.4" + csslib: + dependency: transitive + description: + name: csslib + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" + dart_style: + dependency: transitive + description: + name: dart_style + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.3" + dartx: + dependency: "direct main" + description: + name: dartx + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.2" + dotted_border: + dependency: "direct main" + description: + name: dotted_border + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + draggable_scrollbar: + dependency: "direct main" + description: + name: draggable_scrollbar + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4" + faker: + dependency: "direct main" + description: + name: faker + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.1" + file: + dependency: transitive + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.0" + file_chooser: + dependency: "direct main" + description: + path: "plugins/file_chooser" + ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + resolved-ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + url: "git://github.com/google/flutter-desktop-embedding.git" + source: git + version: "0.2.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_hooks: + dependency: "direct main" + description: + name: flutter_hooks + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + github: + dependency: "direct main" + description: + name: github + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.3" + glob: + dependency: transitive + description: + name: glob + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.4" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.8.4" + googleapis: + dependency: "direct main" + description: + name: googleapis + url: "https://pub.dartlang.org" + source: hosted + version: "0.54.0" + html: + dependency: transitive + description: + name: html + url: "https://pub.dartlang.org" + source: hosted + version: "0.14.0+3" + http: + dependency: "direct main" + description: + name: http + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0+4" + http_parser: + dependency: transitive + description: + name: http_parser + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.3" + image: + dependency: "direct main" + description: + name: image + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.12" + intl: + dependency: "direct main" + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.16.1" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.1+1" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.5" + logging: + dependency: transitive + description: + name: logging + url: "https://pub.dartlang.org" + source: hosted + version: "0.11.4" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.6" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.8" + nested: + dependency: transitive + description: + name: nested + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4" + node_interop: + dependency: transitive + description: + name: node_interop + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + node_io: + dependency: transitive + description: + name: node_io + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1+2" + package_config: + dependency: transitive + description: + name: package_config + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + package_info: + dependency: "direct main" + description: + name: package_info + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.0+16" + path: + dependency: "direct main" + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.4" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.4" + path_provider: + dependency: "direct main" + description: + name: path_provider + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.1" + path_provider_fde: + dependency: "direct main" + description: + path: "plugins/flutter_plugins/path_provider_fde" + ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + resolved-ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + url: "git://github.com/google/flutter-desktop-embedding.git" + source: git + version: "0.0.1" + path_provider_macos: + dependency: "direct main" + description: + name: path_provider_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.4" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.0" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.2" + platform: + dependency: transitive + description: + name: platform + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.2" + process: + dependency: transitive + description: + name: process + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.12" + provider: + dependency: "direct main" + description: + name: provider + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0-dev+2" + pub_semver: + dependency: transitive + description: + name: pub_semver + url: "https://pub.dartlang.org" + source: hosted + version: "1.4.3" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.5" + quiver: + dependency: transitive + description: + name: quiver + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.3" + rnd: + dependency: "direct main" + description: + name: rnd + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.6+2" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+6" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2+4" + sized_context: + dependency: "direct main" + description: + name: sized_context + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.1+1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.5" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + statsfl: + dependency: "direct main" + description: + name: statsfl + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.3" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + styled_widget: + dependency: "direct main" + description: + name: styled_widget + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + textstyle_extensions: + dependency: "direct main" + description: + name: textstyle_extensions + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + time: + dependency: transitive + description: + name: time + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + timeago: + dependency: "direct main" + description: + name: timeago + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.26" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + universal_platform: + dependency: "direct main" + description: + name: universal_platform + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.2+1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "5.4.5" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "0.0.1+4" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.6" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.1+1" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" + watcher: + dependency: transitive + description: + name: watcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.9.7+13" + window_size: + dependency: "direct main" + description: + path: "plugins/window_size" + ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + resolved-ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + url: "git://github.com/google/flutter-desktop-embedding.git" + source: git + version: "0.1.0" + xdg_directories: + dependency: "direct main" + description: + name: xdg_directories + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "3.7.0" + yaml: + dependency: transitive + description: + name: yaml + url: "https://pub.dartlang.org" + source: hosted + version: "2.2.0" +sdks: + dart: ">=2.9.0-14.0.dev <3.0.0" + flutter: ">=1.15.17-pre.5 <2.0.0" diff --git a/flokk_src/pubspec.yaml b/flokk_src/pubspec.yaml new file mode 100644 index 0000000..7c725f3 --- /dev/null +++ b/flokk_src/pubspec.yaml @@ -0,0 +1,100 @@ +name: flokk +description: A fresh and modern Google Contacts manager that integrates with GitHub and Twitter. +version: 1.0.1 +environment: + sdk: ">=2.6.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + animations: ^1.0.0+5 + cupertino_icons: ^0.1.3 + dartx: ^0.4.0 + dotted_border: ^1.0.5 + draggable_scrollbar: ^0.0.4 + faker: ^1.2.1 + flutter_hooks: ^0.8.0 + #flutter_svg: ^0.18.0 # removed due to issues with Web builds + github: ^6.1.3 + google_sign_in: ^4.1.4 + googleapis: ^0.54.0 + http: ^0.12.0+4 + image: ^2.1.12 + intl: ^0.16.1 + json_annotation: ^3.0.0 + package_info: ^0.4.0+13 + path: ^1.6.4 + path_provider: 1.6.1 + path_provider_macos: ^0.0.4 + provider: ^4.1.0-dev+2 + rnd: ^0.1.0 + shared_preferences: + sized_context: ^0.2.1 + statsfl: ^0.0.3 + styled_widget: ^0.2.1 + textstyle_extensions: ^1.0.0 + timeago: ^2.0.26 + tuple: ^1.0.3 + universal_platform: ^0.1.2 + url_launcher: + xdg_directories: ^0.1.0 + + file_chooser: + git: + url: git://github.com/google/flutter-desktop-embedding.git + path: plugins/file_chooser + ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + path_provider_fde: + git: + url: git://github.com/google/flutter-desktop-embedding.git + path: plugins/flutter_plugins/path_provider_fde + ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + window_size: + git: + url: git://github.com/google/flutter-desktop-embedding.git + path: plugins/window_size + ref: b0794faf2c000576515aee56ca6bb5bee64cece4 + +dev_dependencies: + json_serializable: ^3.2.0 # Generate JSON parsers: flutter pub run build_runner build --delete-conflicting-outputs + +flutter: + uses-material-design: true + fonts: + - family: Quicksand + fonts: + - asset: assets/fonts/Quicksand-Light.ttf + weight: 200 + - asset: assets/fonts/Quicksand-Regular.ttf + weight: 400 + - asset: assets/fonts/Quicksand-SemiBold.ttf + weight: 500 + - asset: assets/fonts/Quicksand-Medium.ttf + weight: 600 + - asset: assets/fonts/Quicksand-Bold.ttf + weight: 700 + + - family: Lato + fonts: + - asset: assets/fonts/Lato-Thin.ttf + weight: 200 + - asset: assets/fonts/Lato-Light.ttf + weight: 300 + - asset: assets/fonts/Lato-Regular.ttf + weight: 400 + - asset: assets/fonts/Lato-Bold.ttf + weight: 700 + - asset: assets/fonts/Lato-Black.ttf + weight: 800 + + - family: OpenSansEmoji + fonts: + - asset: assets/fonts/OpenSansEmoji.ttf + + assets: + - assets/images/ + - assets/images/birds/ + - assets/images/flokk-logo.svg + - assets/images/avatars/ + - assets/icons/ + diff --git a/flokk_src/web/favicon.png b/flokk_src/web/favicon.png new file mode 100644 index 0000000..2fdf4b2 Binary files /dev/null and b/flokk_src/web/favicon.png differ diff --git a/flokk_src/web/flokk-logo.png b/flokk_src/web/flokk-logo.png new file mode 100644 index 0000000..709be68 Binary files /dev/null and b/flokk_src/web/flokk-logo.png differ diff --git a/flokk_src/web/icons/Icon-192.png b/flokk_src/web/icons/Icon-192.png new file mode 100644 index 0000000..12847c4 Binary files /dev/null and b/flokk_src/web/icons/Icon-192.png differ diff --git a/flokk_src/web/icons/Icon-512.png b/flokk_src/web/icons/Icon-512.png new file mode 100644 index 0000000..6c27295 Binary files /dev/null and b/flokk_src/web/icons/Icon-512.png differ diff --git a/flokk_src/web/index.html b/flokk_src/web/index.html new file mode 100644 index 0000000..0031867 --- /dev/null +++ b/flokk_src/web/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + Flokk + + + + + + + + diff --git a/flokk_src/web/manifest.json b/flokk_src/web/manifest.json new file mode 100644 index 0000000..aa6b900 --- /dev/null +++ b/flokk_src/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Flokk Contacts", + "short_name": "Flokk", + "start_url": ".", + "display": "minimal-ui", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A fresh and modern Google Contacts manager that integrates with GitHub and Twitter.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/flokk_src/web/privacy.html b/flokk_src/web/privacy.html new file mode 100644 index 0000000..e80c3a2 --- /dev/null +++ b/flokk_src/web/privacy.html @@ -0,0 +1,345 @@ + + + + Privacy Policy for Flokk Contacts + + + + +
+ + +
+

Flokk Contacts Privacy Policy & Licenses

+ +

Privacy Policy

+ +

This privacy policy document outlines the types of personal information that is received and collected by + the Flokk Contacts app and how it is used.

+ +

Flokk Contacts stores data on your local disk, and on your Google Contacts account. We do not store your + data on our own servers or any other third parties.

+ +

The privacy policy for Google Contacts can be found at https://policies.google.com/privacy?hl=en-US. +

+ +

If you require any more information or have any questions about our privacy policy, please feel free to + contact us by email at support@flokk.app.

+ +

Licenses

+ +

animations (BSD)

+ https://pub.dev/packages/animations + https://github.com/flutter/packages/tree/master/packages/animations/LICENSE + +

build_runner (BSD)

+ https://pub.dev/packages/build_runner + https://github.com/dart-lang/build/tree/master/build_runner/LICENSE + +

cupertino_icons (MIT)

+ https://pub.dev/packages/cupertino_icons + https://github.com/flutter/cupertino_icons/blob/master/LICENSE + +

dartx (Apache 2.0)

+ https://pub.dev/packages/dartx + https://github.com/leisim/dartx/blob/master/LICENSE + +

dotted_border (MIT)

+ https://pub.dev/packages/dotted_border + https://github.com/ajilo297/Flutter-Dotted-Border/blob/master/LICENSE + +

draggable_scrollbar (MIT)

+ https://pub.dev/packages/draggable_scrollbar + https://github.com/fluttercommunity/flutter-draggable-scrollbar/blob/master/LICENSE + +

faker (MIT)

+ https://pub.dev/packages/faker + https://github.com/drager/faker/blob/master/LICENSE + +

file_chooser (Apache 2.0)

+ https://github.com/google/flutter-desktop-embedding/tree/master/plugins/file_chooser + https://github.com/google/flutter-desktop-embedding/blob/master/plugins/file_chooser/LICENSE + +

flutter (BSD)

+ https://github.com/flutter/flutter + https://github.com/flutter/flutter/blob/master/LICENSE + +

flutter_hooks (MIT)

+ https://pub.dev/packages/flutter_hooks + https://github.com/rrousselGit/flutter_hooks/blob/master/LICENSE + + +

flutter_svg (MIT)

+ https://pub.dev/packages/flutter_svg + https://github.com/dnfield/flutter_svg/blob/master/LICENSE + +

github (MIT)

+ https://pub.dev/packages/github + https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE + +

google_sign_in (BSD)

+ https://pub.dev/packages/google_sign_in + https://github.com/flutter/plugins/tree/master/packages/google_sign_in/google_sign_in/LICENSE + +

googleapis (BSD)

+ https://pub.dev/packages/googleapis + https://github.com/dart-lang/googleapis/blob/master/LICENSE + +

http (BSD)

+ https://pub.dev/packages/http + https://github.com/dart-lang/http/blob/master/LICENSE + +

image (Apache 2.0)

+ https://pub.dev/packages/image + https://github.com/brendan-duncan/image/blob/master/LICENSE + +

intl (BSD)

+ https://pub.dev/packages/intl + https://github.com/dart-lang/intl/blob/master/LICENSE + +

json_annotation (BSD)

+ https://pub.dev/packages/json_annotation + https://github.com/dart-lang/json_serializable/blob/master/LICENSE + +

json_serializable (BSD)

+ https://pub.dev/packages/json_serializable + https://github.com/dart-lang/json_serializable/blob/master/LICENSE + +

package_info (BSD)

+ https://pub.dev/packages/package_info + https://github.com/flutter/plugins/tree/master/packages/package_info/LICENSE + +

path (BSD)

+ https://pub.dev/packages/path + https://github.com/dart-lang/path/blob/master/LICENSE + +

path_provider (BSD)

+ https://pub.dev/packages/path_provider + https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider/LICENSE + +

path_provider_fde (Apache 2.0)

+ https://github.com/google/flutter-desktop-embedding/tree/master/plugins/flutter_plugins/path_provider_fde + https://github.com/google/flutter-desktop-embedding/blob/master/plugins/flutter_plugins/path_provider_fde/LICENSE + +

path_provider_macos (BSD)

+ https://pub.dev/packages/path_provider_macos + https://github.com/flutter/plugins/tree/master/packages/path_provider/path_provider_macos/LICENSE + +

provider (MIT)

+ https://pub.dev/packages/provider + https://github.com/rrousselGit/provider/blob/master/LICENSE + +

rnd (BSD)

+ https://pub.dev/packages/rnd + https://github.com/gskinner/dart_rnd/blob/master/LICENSE + +

shared_preferences (BSD)

+ https://pub.dev/packages/shared_preferences + https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences/LICENSE + +

sized_context (MIT)

+ https://pub.dev/packages/sized_context + https://github.com/gskinnerTeam/flutter-sized-context/blob/master/LICENSE +

statsfl (MIT)

+ https://pub.dev/packages/statsfl + https://github.com/gskinnerTeam/flutter-stats-fl/blob/master/LICENSE + +

styled_widget (MIT)

+ https://pub.dev/packages/styled_widget + https://github.com/ReinBentdal/styled_widget/blob/master/LICENSE + +

textstyle_extensions (MIT)

+ https://pub.dev/packages/textstyle_extensions + https://github.com/gskinnerTeam/flutter-textstyle-extensions/blob/master/LICENSE + +

timeago (MIT)

+ https://pub.dev/packages/timeago + https://github.com/andresaraujo/timeago.dart/blob/master/timeago/LICENSE + +

tuple (BSD)

+ https://pub.dev/packages/tuple + https://github.com/dart-lang/tuple/blob/master/LICENSE + +

tweet_ui (MIT)

+ https://pub.dev/packages/tweet_ui + https://github.com/schibsted/tweet_ui/blob/master/LICENSE + +

universal_platform (MIT)

+ https://pub.dev/packages/universal_platform + https://github.com/gskinnerTeam/flutter-universal-platform/blob/master/LICENSE + +

url_launcher (BSD)

+ https://pub.dev/packages/url_launcher + https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher/LICENSE + +

window_size (Apache 2.0)

+ https://github.com/google/flutter-desktop-embedding/tree/master/plugins/window_size + https://github.com/google/flutter-desktop-embedding/blob/master/plugins/window_size/LICENSE + +

xdg_directories (BSD)

+ https://pub.dev/packages/xdg_directories + https://github.com/flutter/packages/tree/master/packages/animations/LICENSE + +
+
+ + + \ No newline at end of file diff --git a/flokk_src/windows/.gitignore b/flokk_src/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/flokk_src/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/flokk_src/windows/AppConfiguration.props b/flokk_src/windows/AppConfiguration.props new file mode 100644 index 0000000..e8554a7 --- /dev/null +++ b/flokk_src/windows/AppConfiguration.props @@ -0,0 +1,6 @@ + + + + Flokk Contacts + + diff --git a/flokk_src/windows/FlutterBuild.vcxproj b/flokk_src/windows/FlutterBuild.vcxproj new file mode 100644 index 0000000..8ef93f9 --- /dev/null +++ b/flokk_src/windows/FlutterBuild.vcxproj @@ -0,0 +1,50 @@ + + + + + Debug + x64 + + + Profile + x64 + + + Release + x64 + + + + 15.0 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} + Flutter Build + 10.0 + + + + v141 + v142 + + + + + + + + + + + + + + "$(ProjectDir)scripts\prepare_dependencies" $(Configuration) + Running Flutter backend build + force_to_run_every_time + + + + + + + + diff --git a/flokk_src/windows/Runner.sln b/flokk_src/windows/Runner.sln new file mode 100644 index 0000000..11dc9cd --- /dev/null +++ b/flokk_src/windows/Runner.sln @@ -0,0 +1,82 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.29709.97 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Runner", "Runner.vcxproj", "{5A827760-CF8B-408A-99A3-B6C0AD2271E7}" + ProjectSection(ProjectDependencies) = postProject + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4} = {D9433AE2-FB49-48D8-A6F9-3C71021E73E4} + {FE855771-7D30-42F7-938E-C57B2AEA68D8} = {FE855771-7D30-42F7-938E-C57B2AEA68D8} + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB} = {9FDE4FCF-34A0-48B0-818B-877485F2AFEB} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Flutter Build", "FlutterBuild.vcxproj", "{6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "file_chooser", "Flutter\ephemeral\.plugin_symlinks\file_chooser\windows\plugin.vcxproj", "{D9433AE2-FB49-48D8-A6F9-3C71021E73E4}" + ProjectSection(ProjectDependencies) = postProject + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "path_provider_fde", "Flutter\ephemeral\.plugin_symlinks\path_provider_fde\windows\plugin.vcxproj", "{FE855771-7D30-42F7-938E-C57B2AEA68D8}" + ProjectSection(ProjectDependencies) = postProject + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} + EndProjectSection +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "window_size", "Flutter\ephemeral\.plugin_symlinks\window_size\windows\plugin.vcxproj", "{9FDE4FCF-34A0-48B0-818B-877485F2AFEB}" + ProjectSection(ProjectDependencies) = postProject + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} = {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F} + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Flutter Plugins", "Flutter Plugins", "{5C2E738A-1DD3-445A-AAC8-EEB9648DD07C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Profile|x64 = Profile|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Debug|x64.ActiveCfg = Debug|x64 + {5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Debug|x64.Build.0 = Debug|x64 + {5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Profile|x64.ActiveCfg = Profile|x64 + {5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Profile|x64.Build.0 = Profile|x64 + {5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Release|x64.ActiveCfg = Release|x64 + {5A827760-CF8B-408A-99A3-B6C0AD2271E7}.Release|x64.Build.0 = Release|x64 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.ActiveCfg = Debug|x64 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Debug|x64.Build.0 = Debug|x64 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Profile|x64.ActiveCfg = Profile|x64 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Profile|x64.Build.0 = Profile|x64 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Release|x64.ActiveCfg = Release|x64 + {6419BF13-6ECD-4CD2-9E85-E566A1F03F8F}.Release|x64.Build.0 = Release|x64 + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4}.Debug|x64.ActiveCfg = Debug|x64 + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4}.Debug|x64.Build.0 = Debug|x64 + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4}.Profile|x64.ActiveCfg = Profile|x64 + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4}.Profile|x64.Build.0 = Profile|x64 + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4}.Release|x64.ActiveCfg = Release|x64 + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4}.Release|x64.Build.0 = Release|x64 + {FE855771-7D30-42F7-938E-C57B2AEA68D8}.Debug|x64.ActiveCfg = Debug|x64 + {FE855771-7D30-42F7-938E-C57B2AEA68D8}.Debug|x64.Build.0 = Debug|x64 + {FE855771-7D30-42F7-938E-C57B2AEA68D8}.Profile|x64.ActiveCfg = Profile|x64 + {FE855771-7D30-42F7-938E-C57B2AEA68D8}.Profile|x64.Build.0 = Profile|x64 + {FE855771-7D30-42F7-938E-C57B2AEA68D8}.Release|x64.ActiveCfg = Release|x64 + {FE855771-7D30-42F7-938E-C57B2AEA68D8}.Release|x64.Build.0 = Release|x64 + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB}.Debug|x64.ActiveCfg = Debug|x64 + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB}.Debug|x64.Build.0 = Debug|x64 + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB}.Profile|x64.ActiveCfg = Profile|x64 + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB}.Profile|x64.Build.0 = Profile|x64 + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB}.Release|x64.ActiveCfg = Release|x64 + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB}.Release|x64.Build.0 = Release|x64 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B8A69CB0-A974-4774-9EBD-1E5EECACD186} + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D9433AE2-FB49-48D8-A6F9-3C71021E73E4} = {5C2E738A-1DD3-445A-AAC8-EEB9648DD07C} + {FE855771-7D30-42F7-938E-C57B2AEA68D8} = {5C2E738A-1DD3-445A-AAC8-EEB9648DD07C} + {9FDE4FCF-34A0-48B0-818B-877485F2AFEB} = {5C2E738A-1DD3-445A-AAC8-EEB9648DD07C} + EndGlobalSection +EndGlobal \ No newline at end of file diff --git a/flokk_src/windows/Runner.vcxproj b/flokk_src/windows/Runner.vcxproj new file mode 100644 index 0000000..3b9ae04 --- /dev/null +++ b/flokk_src/windows/Runner.vcxproj @@ -0,0 +1,262 @@ + + + + + Debug + x64 + + + Profile + x64 + + + Release + x64 + + + + 15.0 + {5A827760-CF8B-408A-99A3-B6C0AD2271E7} + flokk_src + 10.0 + + + + Application + true + v141 + v142 + Unicode + + + Application + false + v141 + v142 + true + Unicode + + + Application + false + v141 + v142 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + + + + + + $(ProjectDir)..\build\windows\$(Platform)\$(Configuration)\$(ProjectName)\ + $(ProjectDir)..\build\windows\intermediates\$(Platform)\$(Configuration)\$(ProjectName)\ + + + $(ProjectDir)..\build\windows\$(Platform)\$(Configuration)\$(ProjectName)\ + $(ProjectDir)..\build\windows\intermediates\$(Platform)\$(Configuration)\$(ProjectName)\ + + + $(ProjectDir)..\build\windows\$(Platform)\$(Configuration)\$(ProjectName)\ + $(ProjectDir)..\build\windows\intermediates\$(Platform)\$(Configuration)\$(ProjectName)\ + + + + Level4 + Disabled + true + true + $(ProjectDir);$(FLUTTER_EPHEMERAL_DIR);$(FLUTTER_EPHEMERAL_DIR)\cpp_client_wrapper\include;%(AdditionalIncludeDirectories) + _MBCS;_HAS_EXCEPTIONS=0;%(PreprocessorDefinitions) + true + 4100 + + + flutter_windows.dll.lib;%(AdditionalDependencies) + $(FLUTTER_EPHEMERAL_DIR);$(OutDir)..\Plugins;%(AdditionalLibraryDirectories) + + + + + + + + + + + + + + + + + + + + + + + "$(ProjectDir)scripts\bundle_assets_and_deps" "$(FLUTTER_EPHEMERAL_DIR)\" "$(OutputPath)" "$(OutputPath)..\Plugins\" "$(TargetFileName)" "$(Configuration)" + + + Bundling dependencies + Dummy_Run_Always + + + + + + + Level4 + MaxSpeed + true + true + true + true + $(ProjectDir);$(FLUTTER_EPHEMERAL_DIR);$(FLUTTER_EPHEMERAL_DIR)\cpp_client_wrapper\include;%(AdditionalIncludeDirectories) + _MBCS;_HAS_EXCEPTIONS=0;%(PreprocessorDefinitions) + true + 4100 + + + true + true + flutter_windows.dll.lib;%(AdditionalDependencies) + $(FLUTTER_EPHEMERAL_DIR);$(OutDir)..\Plugins;%(AdditionalLibraryDirectories) + + + + + + + + + + + + + + + + + + + + + + + "$(ProjectDir)scripts\bundle_assets_and_deps" "$(FLUTTER_EPHEMERAL_DIR)\" "$(OutputPath)" "$(OutputPath)..\Plugins\" "$(TargetFileName)" "$(Configuration)" + + + Bundling dependencies + Dummy_Run_Always + + + + + + + Level4 + MaxSpeed + true + true + true + true + $(ProjectDir);$(FLUTTER_EPHEMERAL_DIR);$(FLUTTER_EPHEMERAL_DIR)\cpp_client_wrapper\include;%(AdditionalIncludeDirectories) + _MBCS;_HAS_EXCEPTIONS=0;%(PreprocessorDefinitions) + true + 4100 + + + true + true + flutter_windows.dll.lib;%(AdditionalDependencies) + $(FLUTTER_EPHEMERAL_DIR);$(OutDir)..\Plugins;%(AdditionalLibraryDirectories) + + + + + + + + + + + + + + + + + + + + + + + "$(ProjectDir)scripts\bundle_assets_and_deps" "$(FLUTTER_EPHEMERAL_DIR)\" "$(OutputPath)" "$(OutputPath)..\Plugins\" "$(TargetFileName)" "$(Configuration)" + + + Bundling dependencies + Dummy_Run_Always + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(SolutionDir) + + diff --git a/flokk_src/windows/Runner.vcxproj.filters b/flokk_src/windows/Runner.vcxproj.filters new file mode 100644 index 0000000..761db86 --- /dev/null +++ b/flokk_src/windows/Runner.vcxproj.filters @@ -0,0 +1,88 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + {2761a4b5-57b2-4d50-a677-d20ddc17a7f1} + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files\Client Wrapper + + + Source Files\Client Wrapper + + + Source Files\Client Wrapper + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + + + + + + Resource Files + + + + + Resource Files + + + diff --git a/flokk_src/windows/flutter/.template_version b/flokk_src/windows/flutter/.template_version new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/flokk_src/windows/flutter/.template_version @@ -0,0 +1 @@ +3 diff --git a/flokk_src/windows/flutter/generated_plugin_registrant.h b/flokk_src/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..9846246 --- /dev/null +++ b/flokk_src/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,13 @@ +// +// Generated file. Do not edit. +// + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/flokk_src/windows/runner/Runner.rc b/flokk_src/windows/runner/Runner.rc new file mode 100644 index 0000000..5b41a82 --- /dev/null +++ b/flokk_src/windows/runner/Runner.rc @@ -0,0 +1,70 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/flokk_src/windows/runner/flutter_window.cpp b/flokk_src/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..fe980cf --- /dev/null +++ b/flokk_src/windows/runner/flutter_window.cpp @@ -0,0 +1,29 @@ +#include "flutter_window.h" + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(RunLoop* run_loop, + const flutter::DartProject& project) + : run_loop_(run_loop), project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +void FlutterWindow::OnCreate() { + Win32Window::OnCreate(); + + // The size here is arbitrary since SetChildContent will resize it. + flutter_controller_ = + std::make_unique(100, 100, project_); + RegisterPlugins(flutter_controller_.get()); + run_loop_->RegisterFlutterInstance(flutter_controller_.get()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + run_loop_->UnregisterFlutterInstance(flutter_controller_.get()); + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} diff --git a/flokk_src/windows/runner/flutter_window.h b/flokk_src/windows/runner/flutter_window.h new file mode 100644 index 0000000..4f41e16 --- /dev/null +++ b/flokk_src/windows/runner/flutter_window.h @@ -0,0 +1,37 @@ +#ifndef FLUTTER_WINDOW_H_ +#define FLUTTER_WINDOW_H_ + +#include +#include + +#include "run_loop.h" +#include "win32_window.h" + +#include + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow driven by the |run_loop|, hosting a + // Flutter view running |project|. + explicit FlutterWindow(RunLoop* run_loop, + const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + void OnCreate() override; + void OnDestroy() override; + + private: + // The run loop driving events for this window. + RunLoop* run_loop_; + + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // FLUTTER_WINDOW_H_ diff --git a/flokk_src/windows/runner/main.cpp b/flokk_src/windows/runner/main.cpp new file mode 100644 index 0000000..11b48e9 --- /dev/null +++ b/flokk_src/windows/runner/main.cpp @@ -0,0 +1,37 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "run_loop.h" +#include "utils.h" +#include "window_configuration.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + RunLoop run_loop; + + flutter::DartProject project(L"data"); + FlutterWindow window(&run_loop, project); + Win32Window::Point origin(kFlutterWindowOriginX, kFlutterWindowOriginY); + Win32Window::Size size(kFlutterWindowWidth, kFlutterWindowHeight); + if (!window.CreateAndShow(kFlutterWindowTitle, origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + run_loop.Run(); + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/flokk_src/windows/runner/resource.h b/flokk_src/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/flokk_src/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/flokk_src/windows/runner/resources/app_icon.ico b/flokk_src/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..008bb35 Binary files /dev/null and b/flokk_src/windows/runner/resources/app_icon.ico differ diff --git a/flokk_src/windows/runner/run_loop.cpp b/flokk_src/windows/runner/run_loop.cpp new file mode 100644 index 0000000..f91d6d4 --- /dev/null +++ b/flokk_src/windows/runner/run_loop.cpp @@ -0,0 +1,70 @@ +#include "run_loop.h" + +#include +// Don't stomp std::min/std::max +#undef max +#undef min + +#include + +RunLoop::RunLoop() {} + +RunLoop::~RunLoop() {} + +void RunLoop::Run() { + bool keep_running = true; + TimePoint next_flutter_event_time = TimePoint::clock::now(); + while (keep_running) { + std::chrono::nanoseconds wait_duration = + std::max(std::chrono::nanoseconds(0), + next_flutter_event_time - TimePoint::clock::now()); + ::MsgWaitForMultipleObjects( + 0, nullptr, FALSE, static_cast(wait_duration.count() / 1000), + QS_ALLINPUT); + bool processed_events = false; + MSG message; + // All pending Windows messages must be processed; MsgWaitForMultipleObjects + // won't return again for items left in the queue after PeekMessage. + while (::PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { + processed_events = true; + if (message.message == WM_QUIT) { + keep_running = false; + break; + } + ::TranslateMessage(&message); + ::DispatchMessage(&message); + // Allow Flutter to process messages each time a Windows message is + // processed, to prevent starvation. + next_flutter_event_time = + std::min(next_flutter_event_time, ProcessFlutterMessages()); + } + // If the PeekMessage loop didn't run, process Flutter messages. + if (!processed_events) { + next_flutter_event_time = + std::min(next_flutter_event_time, ProcessFlutterMessages()); + } + } +} + +void RunLoop::RegisterFlutterInstance( + flutter::FlutterViewController* flutter_instance) { + flutter_instances_.insert(flutter_instance); +} + +void RunLoop::UnregisterFlutterInstance( + flutter::FlutterViewController* flutter_instance) { + flutter_instances_.erase(flutter_instance); +} + +RunLoop::TimePoint RunLoop::ProcessFlutterMessages() { + TimePoint next_event_time = TimePoint::max(); + for (auto flutter_controller : flutter_instances_) { + std::chrono::nanoseconds wait_duration = + flutter_controller->ProcessMessages(); + if (wait_duration != std::chrono::nanoseconds::max()) { + next_event_time = + std::min(next_event_time, TimePoint::clock::now() + wait_duration); + } + } + return next_event_time; +} diff --git a/flokk_src/windows/runner/run_loop.h b/flokk_src/windows/runner/run_loop.h new file mode 100644 index 0000000..442a58e --- /dev/null +++ b/flokk_src/windows/runner/run_loop.h @@ -0,0 +1,40 @@ +#ifndef RUN_LOOP_H_ +#define RUN_LOOP_H_ + +#include + +#include +#include + +// A runloop that will service events for Flutter instances as well +// as native messages. +class RunLoop { + public: + RunLoop(); + ~RunLoop(); + + // Prevent copying + RunLoop(RunLoop const&) = delete; + RunLoop& operator=(RunLoop const&) = delete; + + // Runs the run loop until the application quits. + void Run(); + + // Registers the given Flutter instance for event servicing. + void RegisterFlutterInstance( + flutter::FlutterViewController* flutter_instance); + + // Unregisters the given Flutter instance from event servicing. + void UnregisterFlutterInstance( + flutter::FlutterViewController* flutter_instance); + + private: + using TimePoint = std::chrono::steady_clock::time_point; + + // Processes all currently pending messages for registered Flutter instances. + TimePoint ProcessFlutterMessages(); + + std::set flutter_instances_; +}; + +#endif // RUN_LOOP_H_ diff --git a/flokk_src/windows/runner/runner.exe.manifest b/flokk_src/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/flokk_src/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/flokk_src/windows/runner/utils.cpp b/flokk_src/windows/runner/utils.cpp new file mode 100644 index 0000000..37501e5 --- /dev/null +++ b/flokk_src/windows/runner/utils.cpp @@ -0,0 +1,22 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} diff --git a/flokk_src/windows/runner/utils.h b/flokk_src/windows/runner/utils.h new file mode 100644 index 0000000..d247a66 --- /dev/null +++ b/flokk_src/windows/runner/utils.h @@ -0,0 +1,8 @@ +#ifndef CONSOLE_UTILS_H_ +#define CONSOLE_UTILS_H_ + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +#endif // CONSOLE_UTILS_H_ diff --git a/flokk_src/windows/runner/win32_window.cpp b/flokk_src/windows/runner/win32_window.cpp new file mode 100644 index 0000000..677a9a6 --- /dev/null +++ b/flokk_src/windows/runner/win32_window.cpp @@ -0,0 +1,249 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + OnCreate(); + + return window != nullptr; +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + auto window = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); + + if (window == nullptr) { + return 0; + } + + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: + RECT rect; + GetClientRect(hwnd, &rect); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + // Messages that are directly forwarded to embedding. + case WM_FONTCHANGE: + SendMessage(child_content_, WM_FONTCHANGE, NULL, NULL); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame; + GetClientRect(window_handle_, &frame); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +void Win32Window::OnCreate() { + // No-op; provided for subclasses. +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/flokk_src/windows/runner/win32_window.h b/flokk_src/windows/runner/win32_window.h new file mode 100644 index 0000000..5cbb5d5 --- /dev/null +++ b/flokk_src/windows/runner/win32_window.h @@ -0,0 +1,96 @@ +#ifndef WIN32_WINDOW_H_ +#define WIN32_WINDOW_H_ + +#include +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. + virtual void OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // WIN32_WINDOW_H_ diff --git a/flokk_src/windows/runner/window_configuration.cpp b/flokk_src/windows/runner/window_configuration.cpp new file mode 100644 index 0000000..85e2d62 --- /dev/null +++ b/flokk_src/windows/runner/window_configuration.cpp @@ -0,0 +1,7 @@ +#include "window_configuration.h" + +const wchar_t* kFlutterWindowTitle = L"Flokk Contacts"; +const unsigned int kFlutterWindowOriginX = 10; +const unsigned int kFlutterWindowOriginY = 10; +const unsigned int kFlutterWindowWidth = 800; +const unsigned int kFlutterWindowHeight = 600; diff --git a/flokk_src/windows/runner/window_configuration.h b/flokk_src/windows/runner/window_configuration.h new file mode 100644 index 0000000..ea5cead --- /dev/null +++ b/flokk_src/windows/runner/window_configuration.h @@ -0,0 +1,18 @@ +#ifndef WINDOW_CONFIGURATION_ +#define WINDOW_CONFIGURATION_ + +// This is a temporary approach to isolate changes that people are likely to +// make to main.cpp, where the APIs are still in flux. This will reduce the +// need to resolve conflicts or re-create changes slightly differently every +// time the Windows Flutter API surface changes. +// +// Longer term there should be simpler configuration options for common +// customizations like this, without requiring native code changes. + +extern const wchar_t* kFlutterWindowTitle; +extern const unsigned int kFlutterWindowOriginX; +extern const unsigned int kFlutterWindowOriginY; +extern const unsigned int kFlutterWindowWidth; +extern const unsigned int kFlutterWindowHeight; + +#endif // WINDOW_CONFIGURATION_ diff --git a/flokk_src/windows/scripts/bundle_assets_and_deps.bat b/flokk_src/windows/scripts/bundle_assets_and_deps.bat new file mode 100644 index 0000000..9efa28f --- /dev/null +++ b/flokk_src/windows/scripts/bundle_assets_and_deps.bat @@ -0,0 +1,44 @@ +@echo off + +set FLUTTER_CACHE_DIR=%~1 +set BUNDLE_DIR=%~2 +set PLUGIN_DIR=%~3 +set EXE_NAME=%~4 +set BUILD_MODE=%~5 + +set DATA_DIR=%BUNDLE_DIR%data + +if not exist "%DATA_DIR%" call mkdir "%DATA_DIR%" +if %errorlevel% neq 0 exit /b %errorlevel% + +:: Write the executable name to the location expected by the Flutter tool. +echo %EXE_NAME%>"%FLUTTER_CACHE_DIR%exe_filename" + +:: Copy the Flutter assets to the data directory. +set FLUTTER_BUILD_DIR=%~dp0..\..\build\ +set ASSET_DIR_NAME=flutter_assets +set TARGET_ASSET_DIR=%DATA_DIR%\%ASSET_DIR_NAME% +if exist "%TARGET_ASSET_DIR%" call rmdir /s /q "%TARGET_ASSET_DIR%" +if %errorlevel% neq 0 exit /b %errorlevel% +call xcopy /s /e /i /q "%FLUTTER_BUILD_DIR%%ASSET_DIR_NAME%" "%TARGET_ASSET_DIR%" +if %errorlevel% neq 0 exit /b %errorlevel% + +:: Copy the icudtl.dat file from the Flutter tree to the data directory. +call xcopy /y /d /q "%FLUTTER_CACHE_DIR%icudtl.dat" "%DATA_DIR%" +if %errorlevel% neq 0 exit /b %errorlevel% + +:: For non-debug modes, copy app.so into the data directory. +if not %BUILD_MODE% == "Debug" ( + call xcopy /y /d /q "%FLUTTER_BUILD_DIR%windows\app.so" "%DATA_DIR%" + if %errorlevel% neq 0 exit /b %errorlevel% +) + +:: Copy the Flutter DLL to the target location. +call xcopy /y /d /q "%FLUTTER_CACHE_DIR%flutter_windows.dll" "%BUNDLE_DIR%" +if %errorlevel% neq 0 exit /b %errorlevel% + +:: Copy any Plugin DLLs to the target location. +if exist "%PLUGIN_DIR%" ( + call xcopy /y /d /q "%PLUGIN_DIR%"*.dll "%BUNDLE_DIR%" + if %errorlevel% neq 0 exit /b %errorlevel% +) diff --git a/flokk_src/windows/scripts/prepare_dependencies.bat b/flokk_src/windows/scripts/prepare_dependencies.bat new file mode 100644 index 0000000..d05238b --- /dev/null +++ b/flokk_src/windows/scripts/prepare_dependencies.bat @@ -0,0 +1,5 @@ +@echo off + +:: Run flutter tool backend. +set BUILD_MODE=%~1 +"%FLUTTER_ROOT%\packages\flutter_tools\bin\tool_backend" windows-x64 %BUILD_MODE% diff --git a/marketing/AppIcons/flokk_app_logo.ico b/marketing/AppIcons/flokk_app_logo.ico new file mode 100644 index 0000000..008bb35 Binary files /dev/null and b/marketing/AppIcons/flokk_app_logo.ico differ diff --git a/marketing/AppIcons/rounded/1024x1024.png b/marketing/AppIcons/rounded/1024x1024.png new file mode 100644 index 0000000..34ccffe Binary files /dev/null and b/marketing/AppIcons/rounded/1024x1024.png differ diff --git a/marketing/AppIcons/rounded/128x128.png b/marketing/AppIcons/rounded/128x128.png new file mode 100644 index 0000000..e75be2c Binary files /dev/null and b/marketing/AppIcons/rounded/128x128.png differ diff --git a/marketing/AppIcons/rounded/16x16.png b/marketing/AppIcons/rounded/16x16.png new file mode 100644 index 0000000..9bb69d8 Binary files /dev/null and b/marketing/AppIcons/rounded/16x16.png differ diff --git a/marketing/AppIcons/rounded/256x256.png b/marketing/AppIcons/rounded/256x256.png new file mode 100644 index 0000000..eccaf7d Binary files /dev/null and b/marketing/AppIcons/rounded/256x256.png differ diff --git a/marketing/AppIcons/rounded/32x32.png b/marketing/AppIcons/rounded/32x32.png new file mode 100644 index 0000000..2fdf4b2 Binary files /dev/null and b/marketing/AppIcons/rounded/32x32.png differ diff --git a/marketing/AppIcons/rounded/512x512.png b/marketing/AppIcons/rounded/512x512.png new file mode 100644 index 0000000..6c27295 Binary files /dev/null and b/marketing/AppIcons/rounded/512x512.png differ diff --git a/marketing/AppIcons/rounded/64x64.png b/marketing/AppIcons/rounded/64x64.png new file mode 100644 index 0000000..1d4cb0a Binary files /dev/null and b/marketing/AppIcons/rounded/64x64.png differ diff --git a/marketing/AppIcons/square/1024x1024.png b/marketing/AppIcons/square/1024x1024.png new file mode 100644 index 0000000..2aab97d Binary files /dev/null and b/marketing/AppIcons/square/1024x1024.png differ diff --git a/marketing/AppIcons/square/128x128.png b/marketing/AppIcons/square/128x128.png new file mode 100644 index 0000000..2b7de12 Binary files /dev/null and b/marketing/AppIcons/square/128x128.png differ diff --git a/marketing/AppIcons/square/16x16.png b/marketing/AppIcons/square/16x16.png new file mode 100644 index 0000000..18e058d Binary files /dev/null and b/marketing/AppIcons/square/16x16.png differ diff --git a/marketing/AppIcons/square/256x256.png b/marketing/AppIcons/square/256x256.png new file mode 100644 index 0000000..452e1e0 Binary files /dev/null and b/marketing/AppIcons/square/256x256.png differ diff --git a/marketing/AppIcons/square/32x32.png b/marketing/AppIcons/square/32x32.png new file mode 100644 index 0000000..6d96e77 Binary files /dev/null and b/marketing/AppIcons/square/32x32.png differ diff --git a/marketing/AppIcons/square/512x512.png b/marketing/AppIcons/square/512x512.png new file mode 100644 index 0000000..9937515 Binary files /dev/null and b/marketing/AppIcons/square/512x512.png differ diff --git a/marketing/AppIcons/square/64x64.png b/marketing/AppIcons/square/64x64.png new file mode 100644 index 0000000..8a8ef2c Binary files /dev/null and b/marketing/AppIcons/square/64x64.png differ diff --git a/marketing/flokk-logo-w.png b/marketing/flokk-logo-w.png new file mode 100644 index 0000000..9d272bc Binary files /dev/null and b/marketing/flokk-logo-w.png differ diff --git a/marketing/flokk-logo.png b/marketing/flokk-logo.png new file mode 100644 index 0000000..709be68 Binary files /dev/null and b/marketing/flokk-logo.png differ diff --git a/marketing/flokk-logo.svg b/marketing/flokk-logo.svg new file mode 100644 index 0000000..3dd9bab --- /dev/null +++ b/marketing/flokk-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/proxy/.gitignore b/proxy/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/proxy/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/proxy/app.js b/proxy/app.js new file mode 100644 index 0000000..dec267e --- /dev/null +++ b/proxy/app.js @@ -0,0 +1,15 @@ +// Listen on a specific port via the PORT environment variable +var port = process.env.PORT || 8080; + +var cors_proxy = require('cors-anywhere'); +cors_proxy.createServer({ + httpsOptions: { + key: "-----BEGIN RSA PRIVATE KEY-----\n{INSERT KEY HERE}\n-----END RSA PRIVATE KEY-----", + cert: "-----BEGIN CERTIFICATE-----\n{INSERT CERT (PUBLIC KEY) HERE}\n-----END CERTIFICATE-----" + }, + originWhitelist: [], //Insert expected location of web build: ie. "https://your-flokk-app.com", "http://localhost", etc. Otherwise, leave blank for no whitelisting + requireHeader: ['origin', 'x-requested-with'], + removeHeaders: ['cookie', 'cookie2'] +}).listen(port, function () { + console.log("Running CORS Anywhere on port " + port); +}); \ No newline at end of file diff --git a/proxy/package-lock.json b/proxy/package-lock.json new file mode 100644 index 0000000..ebf65a9 --- /dev/null +++ b/proxy/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "proxy", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "cors-anywhere": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/cors-anywhere/-/cors-anywhere-0.4.3.tgz", + "integrity": "sha512-x+pmjGZsoWrPMPbHdga8yVYYys0aaDLezP+V3uOX3GLqWlCMRmcFyXqrdmi/DP5SN6f5mxtUtAmzHO4u3DohSg==", + "requires": { + "http-proxy": "1.11.1", + "proxy-from-env": "0.0.1" + } + }, + "eventemitter3": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-1.2.0.tgz", + "integrity": "sha1-HIaZHYFq0eUEdQ5zh0Ik7PO+xQg=" + }, + "http-proxy": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.11.1.tgz", + "integrity": "sha1-cd9VdX6ALVjqgQ3yJEAZ3aBa6F0=", + "requires": { + "eventemitter3": "1.x.x", + "requires-port": "0.x.x" + } + }, + "proxy-from-env": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-0.0.1.tgz", + "integrity": "sha1-snxJRunm1dutt1mKZDXTAUxM/Uk=" + }, + "requires-port": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-0.0.1.tgz", + "integrity": "sha1-S0QUQR2d98hVmV3YmajHiilRwW0=" + } + } +} diff --git a/proxy/package.json b/proxy/package.json new file mode 100644 index 0000000..e60654b --- /dev/null +++ b/proxy/package.json @@ -0,0 +1,14 @@ +{ + "name": "proxy", + "version": "1.0.0", + "description": "", + "main": "app.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "cors-anywhere": "^0.4.3" + } +} diff --git a/sample_data.zip b/sample_data.zip new file mode 100644 index 0000000..92b45f8 Binary files /dev/null and b/sample_data.zip differ diff --git a/snap_package/.gitignore b/snap_package/.gitignore new file mode 100644 index 0000000..6bf337b --- /dev/null +++ b/snap_package/.gitignore @@ -0,0 +1,2 @@ +dist* +*.snap diff --git a/snap_package/build.sh b/snap_package/build.sh new file mode 100644 index 0000000..bbbc2f3 --- /dev/null +++ b/snap_package/build.sh @@ -0,0 +1,6 @@ +#!/bin/sh +mkdir -p dist +cp -R ../contacts_manager_flutter_src/build/linux/release/* dist +tar czf dist.tar.gz dist +snapcraft --use-lxd + diff --git a/snap_package/snap/gui/flokk-contacts.desktop b/snap_package/snap/gui/flokk-contacts.desktop new file mode 100644 index 0000000..16f1601 --- /dev/null +++ b/snap_package/snap/gui/flokk-contacts.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Name=Flokk Contacts +Comment=Manage your contacts +Icon=${SNAP}/meta/gui/icon.png +Exec=flokk-contacts %U +Terminal=false +Type=Application +Categories=Office;ContactManagement; +Keywords=Contacts; +StartupNotify=false diff --git a/snap_package/snap/gui/icon.png b/snap_package/snap/gui/icon.png new file mode 100644 index 0000000..34ccffe Binary files /dev/null and b/snap_package/snap/gui/icon.png differ diff --git a/snap_package/snap/snapcraft.yaml b/snap_package/snap/snapcraft.yaml new file mode 100644 index 0000000..5f82416 --- /dev/null +++ b/snap_package/snap/snapcraft.yaml @@ -0,0 +1,24 @@ +name: flokk-contacts +version: 0.9.0 +summary: Flokk Contacts +description: Manage Contacts +confinement: strict +base: core18 +grade: devel +parts: + snaptest: + plugin: dump + source: ./dist.tar.gz + +apps: + flokk-contacts: + command: flokk_contacts + extensions: [gnome-3-28] + plugs: + - x11 + - opengl + - network + - home + common-id: flokk-contacts + environment: + __EGL_VENDOR_LIBRARY_DIRS: $__EGL_VENDOR_LIBRARY_DIRS:$SNAP/gnome-platform/usr/share/glvnd/egl_vendor.d