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