diff --git a/.env.example b/.env.example index deba5fa..77d0676 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ POSTGRES_PORT= POSTGRES_DB= POSTGRES_HOST= WORKER_API_KEY= + +HOSTNAME=aeris.com WORKER_API_URL= WORKER_URL= DISCORD_CLIENT_ID= @@ -18,3 +20,4 @@ SPOTIFY_CLIENT_ID= SPOTIFY_SECRET= ANILIST_SECRET= ANILIST_CLIENT_ID= +BACK_URL= diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7a122fc..1a8dccc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,6 @@ jobs: - name: Run Docker run: docker run -v $PWD:/dist aeris_mobile_build - name: Upload build artifact - if: github.ref == 'refs/head/master' uses: actions/upload-artifact@v2 with: name: aeris_apk diff --git a/api/services/anilist.json b/api/services/anilist.json index 54aab94..44166cf 100644 --- a/api/services/anilist.json +++ b/api/services/anilist.json @@ -3,7 +3,7 @@ "actions": [], "reactions": [ { - "name": "UpdateAbout", + "name": "Anilist_UpdateAbout", "description": { "en": "Update the about you section.", "fr": "Mets à jour votre section \"À propos de moi\"." @@ -25,7 +25,7 @@ "returns": [] }, { - "name": "ToggleFavourite", + "name": "Anilist_ToggleFavourite", "description": { "en": "Add or remove an anime from your favorite.", "fr": "Ajoute ou retire un animé de vos favoris." diff --git a/api/services/spotify.json b/api/services/spotify.json index d5e1d19..5d4c9df 100644 --- a/api/services/spotify.json +++ b/api/services/spotify.json @@ -69,6 +69,7 @@ ], "reactions": [ { +<<<<<<< HEAD "name": "PlayTrack", "description": { "en": "Play a track", @@ -78,6 +79,10 @@ "en": "Play a track", "fr": "Joue une musique" }, +======= + "name": "Spotify_PlayTrack", + "description": "Play a track", +>>>>>>> 26566154676c8e279c3615522129e61b851c75a9 "params": [ { "name": "artist", @@ -134,6 +139,7 @@ "returns": [] }, { +<<<<<<< HEAD "name": "AddTrackToLibrary", "description": { "en": "Add a track to library", @@ -143,6 +149,10 @@ "en": "Add a song to library", "fr": "Ajoute une musique à votre librarie" }, +======= + "name": "Spotify_AddTrackToLibrary", + "description": "Add a track to library", +>>>>>>> 26566154676c8e279c3615522129e61b851c75a9 "params": [ { "name": "artist", diff --git a/api/src/Api/OIDC.hs b/api/src/Api/OIDC.hs index 5b0244f..fd96da9 100644 --- a/api/src/Api/OIDC.hs +++ b/api/src/Api/OIDC.hs @@ -40,34 +40,34 @@ urlHandler :: Service -> Maybe String -> AppM NoContent urlHandler _ Nothing = throwError err400 urlHandler Anilist (Just r) = do clientId <- liftIO $ envAsString "ANILIST_CLIENT_ID" "" - backRedirect <- liftIO $ envAsString "BACK_REDIRECT_URL" "" + backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://anilist.co/api/v2/oauth/authorize?client_id=" ++ clientId ++ "&response_type=code&redirect_uri=" ++ backRedirect ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://anilist.co/api/v2/oauth/authorize?client_id=" ++ clientId ++ "&response_type=code&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } urlHandler Discord (Just r) = do clientId <- liftIO $ envAsString "DISCORD_CLIENT_ID" "" - backRedirect <- liftIO $ envAsString "BACK_REDIRECT_URL" "" + backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://discord.com/api/oauth2/authorize?response_type=code&scope=identify&client_id=" ++ clientId ++ "&response_type=code&redirect_uri=" ++ backRedirect ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://discord.com/api/oauth2/authorize?response_type=code&scope=identify%20guilds%20messages.read%20activities.write%20webhook.incoming&client_id=" ++ clientId ++ "&response_type=code&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } urlHandler Google (Just r) = do clientId <- liftIO $ envAsString "GOOGLE_CLIENT_ID" "" - backRedirect <- liftIO $ envAsString "BACK_REDIRECT_URL" "" + backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/youtube.force-ssl&access_type=offline&prompt=consent&include_granted_scopes=true&response_type=code&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/youtube.force-ssl&access_type=offline&prompt=consent&include_granted_scopes=true&response_type=code&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } urlHandler Twitter (Just r) = do clientId <- liftIO $ envAsString "TWITTER_CLIENT_ID" "" - backRedirect <- liftIO $ envAsString "BACK_REDIRECT_URL" "" + backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://twitter.com/i/oauth2/authorize?response_type=code&scope=like.write like.read follows.read follows.write offline.access tweet.read tweet.write&code_challenge=challenge&code_challenge_method=plain&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://twitter.com/i/oauth2/authorize?response_type=code&scope=like.write like.read follows.read follows.write offline.access tweet.read tweet.write&code_challenge=challenge&code_challenge_method=plain&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } urlHandler Spotify (Just r) = do clientId <- liftIO $ envAsString "SPOTIFY_CLIENT_ID" "" - backRedirect <- liftIO $ envAsString "BACK_REDIRECT_URL" "" + backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://accounts.spotify.com/authorize?response_type=code&scope=user-library-read&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://accounts.spotify.com/authorize?response_type=code&scope=user-library-read&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } urlHandler Github (Just r) = do clientId <- liftIO $ envAsString "GITHUB_CLIENT_ID" "" - backRedirect <- liftIO $ envAsString "BACK_REDIRECT_URL" "" + backRedirect <- liftIO $ envAsString "BACK_URL" "" throwError $ err302 { errHeaders = - [("Location", B8.pack $ "https://github.com/login/oauth/authorize?response_type=code&scope=repo&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "&state=" ++ r)] } + [("Location", B8.pack $ "https://github.com/login/oauth/authorize?response_type=code&scope=repo&client_id=" ++ clientId ++ "&redirect_uri=" ++ backRedirect ++ "auth/redirect" ++ "&state=" ++ r)] } servicesHandler :: AuthRes -> AppM [String] servicesHandler (Authenticated (User uid name slug)) = do diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2f49579..45d7529 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -19,6 +19,7 @@ services: depends_on: - "db" environment: + - BACK_URL=${BACK_URL} - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_HOST=${POSTGRES_HOST} @@ -59,7 +60,6 @@ services: - ANILIST_SECRET=${ANILIST_SECRET} - WORKER_API_URL=${WORKER_API_URL} - WORKER_API_KEY=${WORKER_API_KEY} - volumes: apk: cache: diff --git a/docker-compose.yml b/docker-compose.yml index ef75283..feedd51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,6 +42,7 @@ services: - POSTGRES_PORT=${POSTGRES_PORT} - WORKER_API_KEY=${WORKER_API_KEY} - WORKER_URL=${WORKER_URL} + - BACK_URL=${BACK_URL} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DISCORD_SECRET=${DISCORD_SECRET} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} diff --git a/mobile/Dockerfile b/mobile/Dockerfile index a12dc0e..22c42db 100644 --- a/mobile/Dockerfile +++ b/mobile/Dockerfile @@ -21,5 +21,5 @@ RUN flutter gen-l10n RUN flutter pub run flutter_launcher_icons:main # Generate native splashscreen RUN flutter pub run flutter_native_splash:create -RUN flutter build apk lib/src/main.dart +RUN flutter build apk lib/main.dart CMD cp ./build/app/outputs/flutter-apk/app-release.apk /dist/aeris_android.apk diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml index dbedc5d..704f300 100644 --- a/mobile/android/app/src/debug/AndroidManifest.xml +++ b/mobile/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,45 @@ - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index d4912bf..704f300 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -25,6 +25,16 @@ + + + + + + + + + + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index fafbab7..fae8f70 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,10 +1,14 @@ PODS: - Flutter (1.0.0) + - flutter_keyboard_visibility (0.0.1): + - Flutter - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) - path_provider_ios (0.0.1): - Flutter + - shared_preferences_ios (0.0.1): + - Flutter - sqflite (0.0.2): - Flutter - FMDB (>= 2.7.5) @@ -13,7 +17,9 @@ PODS: DEPENDENCIES: - Flutter (from `Flutter`) + - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`) + - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -24,8 +30,12 @@ SPEC REPOS: EXTERNAL SOURCES: Flutter: :path: Flutter + flutter_keyboard_visibility: + :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" path_provider_ios: :path: ".symlinks/plugins/path_provider_ios/ios" + shared_preferences_ios: + :path: ".symlinks/plugins/shared_preferences_ios/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: @@ -33,8 +43,10 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 + shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 4fb782a..3b5299a 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ /* Begin PBXFileReference section */ 0B9CFEF16F77B4F2467EF56A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 0C6C3D7227D0D7C100B12C20 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; @@ -99,6 +100,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 0C6C3D7227D0D7C100B12C20 /* RunnerDebug.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -217,7 +219,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; 755B6DB6A94E09EAD68F291E /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -248,7 +250,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -476,6 +478,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = HJ45QP4WWR; ENABLE_BITCODE = NO; diff --git a/mobile/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json b/mobile/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json index 7f6f7e7..1271227 100644 --- a/mobile/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json +++ b/mobile/ios/Runner/Assets.xcassets/BrandingImage.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "icon.jpg", + "filename" : "BrandingImage.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "icon-1.jpg", + "filename" : "BrandingImage@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "icon-2.jpg", + "filename" : "BrandingImage@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/mobile/ios/Runner/Info-Debug.plist b/mobile/ios/Runner/Info-Debug.plist index 6cdf697..6002f7c 100644 --- a/mobile/ios/Runner/Info-Debug.plist +++ b/mobile/ios/Runner/Info-Debug.plist @@ -47,5 +47,20 @@ UIViewControllerBasedStatusBarAppearance + FlutterDeepLinkingEnabled + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + arthichaud.me + CFBundleURLSchemes + + aeris + + + diff --git a/mobile/ios/Runner/Info-Release.plist b/mobile/ios/Runner/Info-Release.plist index f759f12..edf6e96 100644 --- a/mobile/ios/Runner/Info-Release.plist +++ b/mobile/ios/Runner/Info-Release.plist @@ -45,5 +45,20 @@ UIStatusBarHidden + FlutterDeepLinkingEnabled + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + arthichaud.me + CFBundleURLSchemes + + aeris + + + \ No newline at end of file diff --git a/mobile/ios/Runner/RunnerDebug.entitlements b/mobile/ios/Runner/RunnerDebug.entitlements new file mode 100644 index 0000000..3fb05ea --- /dev/null +++ b/mobile/ios/Runner/RunnerDebug.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.default-data-protection + NSFileProtectionComplete + + diff --git a/mobile/lib/l10n/app_en.arb b/mobile/lib/l10n/app_en.arb index cb6d5bf..382a4b8 100644 --- a/mobile/lib/l10n/app_en.arb +++ b/mobile/lib/l10n/app_en.arb @@ -36,5 +36,12 @@ "pipelineFormMisingAction": "You must select at least a trigger and a reaction", "logoutWarningMessage": "You are about to logout, are you sure?", "warning": "Warning", - "cancel": "Cancel" + "cancel": "Cancel", + "errorOnSignup": "An error occured while signing you up, please try again", + "loading": "Loading...", + "invalidUrl": "Invalid URL", + "tryToConnect": "Try to connect", + "routeToApi": "Route to API", + "setupAPIRoute": "Setup API Route", + "paramInheritTip": "To inherit parameters from previous actions, type '{' in the text field and tap on the choosen parameter" } \ No newline at end of file diff --git a/mobile/lib/l10n/app_fr.arb b/mobile/lib/l10n/app_fr.arb index 3a54dbc..776233e 100644 --- a/mobile/lib/l10n/app_fr.arb +++ b/mobile/lib/l10n/app_fr.arb @@ -6,8 +6,8 @@ "today": "Aujourd'hui", "nameOfThePipeline": "Nom de la pipeline", "addReaction": "Ajouter une Reaction", - "addTrigger": "Ajouter un Déclancheur", - "setupTrigger": "Gérer une Déclancheur", + "addTrigger": "Ajouter un déclencheur", + "setupTrigger": "Gérer un déclencheur", "setupReaction": "Gérer une Réaction", "action": "Action", "reactions": "Réactions", @@ -33,8 +33,15 @@ "dangerZone": "Zone dangereuse", "createNewPipeline": "Créer une nouvelle pipeline", "save": "Enregistrer", - "pipelineFormMisingAction": "Vous devez selectionner au moins un déclancheur et une réaction", + "pipelineFormMisingAction": "Vous devez selectionner au moins un déclencheur et une réaction", "logoutWarningMessage": "Êtes-vous sûr(e) de voulour vous déconnecter d'Aeris?", "warning": "Attention", - "cancel": "Annuler" + "cancel": "Annuler", + "errorOnSignup": "Une erreur est survenue, veuillez réessayer", + "loading": "Chargement...", + "invalidUrl": "URL invalide", + "tryToConnect": "Tester la connection", + "routeToApi": "Route de l'API", + "setupAPIRoute": "Choisir la route de l'API", + "paramInheritTip": "Afin d'hériter de variables venant d'actions précedentes, entrez '{' dans un champ et choisissez la valeur" } \ No newline at end of file diff --git a/mobile/lib/src/main.dart b/mobile/lib/main.dart similarity index 65% rename from mobile/lib/src/main.dart rename to mobile/lib/main.dart index 8e1f8a5..4529b27 100644 --- a/mobile/lib/src/main.dart +++ b/mobile/lib/main.dart @@ -1,8 +1,10 @@ import 'package:aeris/src/aeris_api.dart'; +import 'package:aeris/src/providers/action_catalogue_provider.dart'; +import 'package:aeris/src/views/authorization_page.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:form_builder_validators/localization/l10n.dart'; import 'package:aeris/src/providers/pipelines_provider.dart'; -import 'package:aeris/src/providers/user_services_provider.dart'; +import 'package:aeris/src/providers/services_provider.dart'; import 'package:aeris/src/views/startup_page.dart'; import 'package:aeris/src/views/login_page.dart'; import 'package:aeris/src/views/home_page.dart'; @@ -11,13 +13,19 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -void main() { +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + SharedPreferences prefs = await SharedPreferences.getInstance(); + GetIt.I.registerSingleton(prefs); AerisAPI interface = AerisAPI(); GetIt.I.registerSingleton(interface); + await interface.restoreConnection(); runApp(MultiProvider(providers: [ ChangeNotifierProvider(create: (_) => PipelineProvider()), - ChangeNotifierProvider(create: (_) => UserServiceProvider()) + ChangeNotifierProvider(create: (_) => ServiceProvider()), + ChangeNotifierProvider(create: (_) => ActionCatalogueProvider(), lazy: false) ], child: const Aeris())); } @@ -48,22 +56,23 @@ class Aeris extends StatelessWidget { '/login': () => const LoginPage(), '/home': () => const HomePage(), }; + return PageRouteBuilder( opaque: false, settings: settings, - pageBuilder: (_, __, ___) => routes[settings.name].call(), + pageBuilder: (_, __, ___) { + if (settings.name!.startsWith('/authorization')) { + return const AuthorizationPage(); + } + return routes[settings.name].call(); + }, transitionDuration: const Duration(milliseconds: 350), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - SlideTransition( - child: child, - position: animation.drive( - Tween( - begin: const Offset(1.0, 0.0), - end: Offset.zero - ) - ) - ) - ); + transitionsBuilder: (context, animation, secondaryAnimation, + child) => + SlideTransition( + child: child, + position: animation.drive(Tween( + begin: const Offset(1.0, 0.0), end: Offset.zero)))); }); } } diff --git a/mobile/lib/src/aeris_api.dart b/mobile/lib/src/aeris_api.dart index 5abf6fe..e89f18e 100644 --- a/mobile/lib/src/aeris_api.dart +++ b/mobile/lib/src/aeris_api.dart @@ -1,127 +1,249 @@ +// ignore_for_file: unused_import + import 'dart:async'; -import 'package:aeris/src/models/action.dart'; +import 'dart:convert'; +import 'dart:io'; +import 'package:aeris/main.dart'; +import 'package:aeris/src/models/action.dart' as aeris; +import 'package:aeris/src/models/action_parameter.dart'; import 'package:aeris/src/models/action_template.dart'; import 'package:aeris/src/models/pipeline.dart'; import 'package:aeris/src/models/reaction.dart'; import 'package:aeris/src/models/service.dart'; import 'package:aeris/src/models/trigger.dart'; +import 'package:aeris/src/providers/action_catalogue_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +extension IsOk on http.Response { + bool get ok => (statusCode ~/ 100) == 2; +} + +/// Requests types supported by Aeris API +enum AerisAPIRequestType { get, post, put, delete } /// Call to interact with Aeris' Back end class AerisAPI { - ///TODO set status based on stored credentials - bool connected = true; - late List fakeAPI; + /// Get Connection state + bool _connected = false; + bool get isConnected => _connected; + + /// JWT token used to request API + late String _jwt; + + late final String deepLinkRoute; + + String _baseRoute = + GetIt.I().getString('api') ?? "http://localhost:8080"; + String get baseRoute => _baseRoute; + set baseRoute(value) => _baseRoute = value; AerisAPI() { - var trigger1 = Trigger( - service: const Service.spotify(), - name: "Play song", - last: DateTime.now()); - var trigger3 = Trigger( - service: const Service.discord(), - name: "Send a message", - last: DateTime.now()); - var trigger2 = Trigger( - service: const Service.spotify(), - name: "Play song", - last: DateTime.parse("2022-01-01")); - var reaction = Reaction( - service: const Service.twitter(), parameters: {}, name: "Post a tweet"); - var reaction2 = Reaction( - service: const Service.gmail(), parameters: {}, name: "Do smth"); - var reaction1 = Reaction( - service: const Service.youtube(), parameters: {}, name: "Do smth youtube"); - var pipeline1 = Pipeline( - id: 10, - name: "My Action", - triggerCount: 1, - enabled: true, - trigger: trigger1, - reactions: [reaction]); - var pipeline2 = Pipeline( - id: 10, - name: "My very long action Action", - triggerCount: 10, - enabled: true, - trigger: trigger2, - reactions: [reaction, reaction1, reaction2]); - var pipeline3 = Pipeline( - id: 10, - name: "Disabled", - triggerCount: 3, - enabled: false, - trigger: trigger3, - reactions: [reaction]); - fakeAPI = [ - pipeline3, - pipeline2, - pipeline1, - pipeline3, - pipeline2, - pipeline1, - pipeline3, - pipeline2, - pipeline1 - ]; + var scheme = "http"; + if (Platform.isIOS) { + scheme = "aeris"; + } + deepLinkRoute = "$scheme://arthichaud.me"; } - /// Adds new pipeline to API - Future createPipeline(Pipeline newPipeline) async { - ///TODO Send Pipeline to API - fakeAPI.add(newPipeline); - await Future.delayed(const Duration(seconds: 2)); - return; + /// Name of the file that contains the JWT used for Aeris' API requestd + static const String jwtFile = 'aeris_jwt.txt'; + + ///ROUTES + /// Registers new user in the database and connects it. Returns false if register failed + Future signUpUser(String username, String password) async { + http.Response response = + await _requestAPI('/auth/signup', AerisAPIRequestType.post, { + 'username': username, + 'password': password, + }); + if (!response.ok) { + return false; + } + return createConnection(username, password); + } + + /// On success, sets API as connected to given user. Returns false if connection false + Future createConnection(String username, String password) async { + http.Response response = + await _requestAPI('/auth/login', AerisAPIRequestType.post, { + 'username': username, + 'password': password, + }); + if (!response.ok) { + return false; + } + try { + final String jwt = jsonDecode(response.body)['jwt']; + await GetIt.I().setString('jwt', jwt); + _connected = true; + _jwt = jwt; + } catch (e) { + return false; + } + return true; + } + + /// Create an API connection using previously created credentials + Future restoreConnection() async { + try { + final cred = GetIt.I().getString('jwt'); + if (cred == "" || cred == null) { + throw Exception("Empty creds"); + } + _jwt = cred; + _connected = true; + } catch (e) { + return; + } + } + + /// Delete JWT file and disconnect from API + Future stopConnection() async { + await GetIt.I().remove('jwt'); + _connected = false; + } + + ///Get /about.json + Future> getAbout() async { + var res = await _requestAPI('/about.json', AerisAPIRequestType.get, null); + if (!res.ok) return {}; + return jsonDecode(res.body); + } + + /// Adds new pipeline to API, returns false if post failed + Future createPipeline(Pipeline newPipeline) async { + var res = await _requestAPI( + '/workflow', AerisAPIRequestType.post, newPipeline.toJSON()); + newPipeline.id = Pipeline.fromJSON(jsonDecode(res.body)).id; + return res.ok; } /// Removes pipeline from API - Future removePipeline(Pipeline pipeline) async { - ///TODO Send delete request to API - fakeAPI.remove(pipeline); - await Future.delayed(const Duration(seconds: 2)); - return; + Future removePipeline(Pipeline pipeline) async { + var res = await _requestAPI( + '/workflow/${pipeline.id}', AerisAPIRequestType.delete, null); + return res.ok; } - Future editPipeline(Pipeline updatedPipeline) async { - ///TODO Send update request to API - for (var pipeline in fakeAPI) { - if (pipeline.id == updatedPipeline.id) { - ///TODO Call Api - break; - } - } + String getServiceAuthURL(Service service) { + final serviceName = service == const Service.youtube() + ? "google" + : service.name.toLowerCase(); + return "$baseRoute/auth/$serviceName/url?redirect_uri=$deepLinkRoute/authorization/$serviceName"; + } - await Future.delayed(const Duration(seconds: 2)); - return; + /// Send PUT request to update Pipeline, returns false if failed + Future editPipeline(Pipeline updatedPipeline) async { + var res = await _requestAPI('/workflow/${updatedPipeline.id}', + AerisAPIRequestType.put, updatedPipeline.toJSON()); + return res.ok; } /// Fetches the Pipelines from the API Future> getPipelines() async { - /// TODO Fetch the API - await Future.delayed(const Duration(seconds: 2)); - return fakeAPI; + var res = await _requestAPI('/workflows', AerisAPIRequestType.get, null); + if (res.ok == false) return []; + final List body = jsonDecode(res.body); + + return body.map((e) => Pipeline.fromJSON(Map.from(e))).toList(); + } + + /// Fetch the services the user is authenticated to + Future> getConnectedService() async { + var res = + await _requestAPI('/auth/services', AerisAPIRequestType.get, null); + if (!res.ok) return []; + return (jsonDecode(res.body) as List) + .map((e) => Service.factory(e.toString())) + .toList(); } /// Disconnects the user from the service - Future disconnectService(Service service) async { - ///TODO disconnect service from user - await Future.delayed(const Duration(seconds: 2)); - return; + Future disconnectService(Service service) async { + var res = await _requestAPI('/auth/${service.name.toLowerCase()}', + AerisAPIRequestType.delete, null); + return res.ok; } - Future> getActionsFor( - Service service, Action action) async { - await Future.delayed(const Duration(seconds: 3)); + /// Connects the user from the service + Future connectService(Service service, String code) async { + var res = await _requestAPI( + '/auth/${service.name.toLowerCase()}?code=$code&redirect_uri=$deepLinkRoute/authorization/${service.name.toLowerCase()}', + AerisAPIRequestType.get, + null); + return res.ok; + } + + List getActionsFor(Service service, aeris.Action action) { + final catalogue = + Aeris.materialKey.currentContext?.read(); if (action is Trigger) { - ///TODO get triggers - } else if (action is Reaction) { - ///TODO get reactions + return catalogue!.triggerTemplates[service]!; + } + return catalogue!.reactionTemplates[service]!; + } + + /// Encodes Uri for request + Uri _encoreUri(String route) { + return Uri.parse('$_baseRoute$route'); + } + + /// Calls API using a HTTP request type, a route and body + Future _requestAPI( + String route, AerisAPIRequestType requestType, Object? body) async { + final Map header = { + 'Content-type': 'application/json', + 'Accept': 'application/json', + }; + if (_connected) { + header.addAll({'Authorization': 'Bearer $_jwt'}); + } + const duration = Duration(seconds: 3); + try { + switch (requestType) { + case AerisAPIRequestType.delete: + return await http + .delete(_encoreUri(route), + body: jsonEncode(body), headers: header) + .timeout( + duration, + onTimeout: () { + return http.Response('Error', 408); + }, + ); + case AerisAPIRequestType.get: + return await http.get(_encoreUri(route), headers: header).timeout( + duration, + onTimeout: () { + return http.Response('Error', 408); + }, + ); + case AerisAPIRequestType.post: + return await http + .post(_encoreUri(route), body: jsonEncode(body), headers: header) + .timeout( + duration, + onTimeout: () { + return http.Response('Error', 408); + }, + ); + case AerisAPIRequestType.put: + return await http + .put(_encoreUri(route), body: jsonEncode(body), headers: header) + .timeout( + duration, + onTimeout: () { + return http.Response('Error', 408); + }, + ); + } + } catch (e) { + return http.Response('{}', 400); } - return [ - for (int i = 0; i <= 10; i++) - ActionTemplate( - service: service, - name: "action$i", - parameters: {'key1': 'value1', 'key2': 'value2'}) - ]; } } diff --git a/mobile/lib/src/models/action.dart b/mobile/lib/src/models/action.dart index 0e675c5..0b90e15 100644 --- a/mobile/lib/src/models/action.dart +++ b/mobile/lib/src/models/action.dart @@ -1,5 +1,7 @@ +import 'package:aeris/src/models/action_parameter.dart'; import 'package:flutter/widgets.dart'; import 'package:aeris/src/models/service.dart'; +import 'package:recase/recase.dart'; ///Base class for reactions and trigger abstract class Action { @@ -10,10 +12,27 @@ abstract class Action { String name; ///Action's parameters - Map parameters; + List parameters; + + /// Description of the action (used in catalogue) + String? description; + Action( {Key? key, required this.service, required this.name, - this.parameters = const {}}); + this.description, + this.parameters = const []}); + + static Service parseServiceInName(String rType) { + var snake = rType.split('_'); + var service = snake.removeAt(0); + return Service.factory(service); + } + + String displayName() { + var words = name.split('_'); + words.removeAt(0); + return ReCase(words.join()).titleCase; + } } diff --git a/mobile/lib/src/models/action_parameter.dart b/mobile/lib/src/models/action_parameter.dart new file mode 100644 index 0000000..5921c8b --- /dev/null +++ b/mobile/lib/src/models/action_parameter.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +/// Object representation of an action's parameter +class ActionParameter { + /// Name of the action parameter + final String name; + + /// Description of theparameter + final String description; + + /// Value of the pamrameter + Object? value; + + ActionParameter( + {Key? key, required this.name, this.description = "", this.value}); + + MapEntry toJson() => MapEntry(name, value); + + static List fromJSON(Map params) { + List actionParameters = []; + params.forEach((key, value) => + actionParameters.add(ActionParameter(name: key, value: value))); + return actionParameters; + } +} diff --git a/mobile/lib/src/models/action_template.dart b/mobile/lib/src/models/action_template.dart index a4c7f40..53ce020 100644 --- a/mobile/lib/src/models/action_template.dart +++ b/mobile/lib/src/models/action_template.dart @@ -1,13 +1,20 @@ import 'package:aeris/src/models/action.dart'; +import 'package:aeris/src/models/action_parameter.dart'; import 'package:aeris/src/models/service.dart'; import 'package:flutter/foundation.dart'; /// Template for actions, for forms class ActionTemplate extends Action { + + ///List of values returned by the action + final List returnedValues; + ActionTemplate( {Key? key, required Service service, required String name, - Map parameters = const {}}) - : super(service: service, name: name, parameters: parameters); + required String description, + this.returnedValues = const [], + List parameters = const []}) + : super(service: service, name: name, parameters: parameters, description: description); } diff --git a/mobile/lib/src/models/pipeline.dart b/mobile/lib/src/models/pipeline.dart index 7b9c9cc..8783726 100644 --- a/mobile/lib/src/models/pipeline.dart +++ b/mobile/lib/src/models/pipeline.dart @@ -5,7 +5,7 @@ import 'package:aeris/src/models/trigger.dart'; /// Object representation of a pipeline class Pipeline { ///Unique identifier - final int id; + int id; /// Name of the pipeline, defined by the user final String name; @@ -16,7 +16,6 @@ class Pipeline { /// Is the pipeline enabled bool enabled; - ///The pipeline's reactions final List reactions; @@ -29,4 +28,34 @@ class Pipeline { required this.enabled, required this.trigger, required this.reactions}); + + /// Unserialize Pipeline from JSON + static Pipeline fromJSON(Map data) { + var action = data['action'] as Map; + var reactions = data['reactions'] as List; + + return Pipeline( + name: action['name'] as String, + enabled: action['enabled'] as bool, + id: action['id'] as int, + triggerCount: action['triggerCount'] as int, + trigger: Trigger.fromJSON(action), + reactions: reactions + .map((e) => Reaction.fromJSON(e)) + .toList()); + } + + /// Serialize Pipeline into JSON + Object toJSON() => { + "action": { + "id": id, + "name": name, + "pType": trigger.name, + "pParams": { for (var e in trigger.parameters) e.name : e.value }, ///Serialize + "enabled": enabled, + "lastTrigger": trigger.last?.toIso8601String(), + "triggerCount": triggerCount + }, + 'reactions': reactions.map((e) => e.toJSON()).toList() + }; } diff --git a/mobile/lib/src/models/reaction.dart b/mobile/lib/src/models/reaction.dart index 1e8cf03..5351609 100644 --- a/mobile/lib/src/models/reaction.dart +++ b/mobile/lib/src/models/reaction.dart @@ -1,6 +1,7 @@ // ignore_for_file: hash_and_equals import 'package:aeris/src/models/action.dart' as aeris_action; +import 'package:aeris/src/models/action_parameter.dart'; import 'package:flutter/widgets.dart'; import 'package:aeris/src/models/service.dart'; @@ -10,20 +11,33 @@ class Reaction extends aeris_action.Action { {Key? key, required Service service, required String name, - Map parameters = const {}}) + List parameters = const []}) : super(service: service, name: name, parameters: parameters); /// Template trigger, used as an 'empty' trigger Reaction.template() - : super(service: Service.all()[0], name: '', parameters: {}); + : super(service: Service.all()[0], name: '', parameters: []); + + static Reaction fromJSON(Object reaction) { + var reactionJSON = reaction as Map; + var service = aeris_action.Action.parseServiceInName(reactionJSON['rType'] as String); + return Reaction( + service: service, + name: reactionJSON['rType'] as String, + parameters: ActionParameter.fromJSON((reactionJSON['rParams'] as Map))); + } + + /// Serialize Reaction to JSON + Object toJSON() => { + "rType": name, + "rParams": { for (var e in parameters) e.name : e.value } + }; @override bool operator ==(Object other) { Reaction otherReaction = other as Reaction; return service.name == otherReaction.service.name && name == otherReaction.name && - parameters.values.toString() == - otherReaction.parameters.values.toString() && - parameters.keys.toString() == otherReaction.parameters.keys.toString(); + parameters.map((e) => e.name).toString() == other.parameters.map((e) => e.name).toString(); } } diff --git a/mobile/lib/src/models/service.dart b/mobile/lib/src/models/service.dart index f963604..2ccf842 100644 --- a/mobile/lib/src/models/service.dart +++ b/mobile/lib/src/models/service.dart @@ -1,6 +1,10 @@ // Class for a service (Youtube, Gmail, ...) +import 'package:aeris/src/aeris_api.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'dart:core'; + +import 'package:get_it/get_it.dart'; /// Data class used to store data about a service (logo, url, name) class Service { @@ -22,16 +26,19 @@ class Service { height: logoSize, )); + /// Get full url for OAuth2 + String get authUrl => GetIt.I().getServiceAuthURL(this); + const Service.spotify() : name = "Spotify", url = "https://www.spotify.com", logoUrl = "https://www.presse-citron.net/app/uploads/2020/06/spotify-une-.jpg"; - const Service.gmail() - : name = "Gmail", - url = "https://mail.google.com/", + const Service.anilist() + : name = "Anilist", + url = "https://anilist.co", logoUrl = - "https://play-lh.googleusercontent.com/KSuaRLiI_FlDP8cM4MzJ23ml3og5Hxb9AapaGTMZ2GgR103mvJ3AAnoOFz1yheeQBBI"; + "https://anilist.co/img/icons/android-chrome-512x512.png"; const Service.discord() : name = "Discord", url = "https://discord.com/app", @@ -43,7 +50,7 @@ class Service { logoUrl = "https://f.hellowork.com/blogdumoderateur/2019/11/twitter-logo-1200x1200.jpg"; const Service.github() - : name = "GitHub", + : name = "Github", url = "https://github.com/", logoUrl = "https://avatars.githubusercontent.com/u/9919?s=280&v=4"; const Service.youtube() @@ -57,13 +64,23 @@ class Service { logoUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/a/af/Cle.png/1024px-Cle.png"; /// Returns a list of all the available services - static all() => const [ + static List all() => const [ Service.discord(), Service.github(), - Service.gmail(), + Service.anilist(), Service.youtube(), Service.twitter(), Service.spotify(), Service.utils(), ]; + + /// Construct a service based on a lowercase string, the name of the service + static Service factory(String name) { + if (name.toLowerCase() == "git") return const Service.github(); + if (name.toLowerCase() == "ani") return const Service.anilist(); + for (Service service in Service.all()) { + if (service.name.toLowerCase() == name.toLowerCase()) return service; + } + throw Exception("Unknown service"); + } } diff --git a/mobile/lib/src/models/trigger.dart b/mobile/lib/src/models/trigger.dart index ed0ac63..5c2ef16 100644 --- a/mobile/lib/src/models/trigger.dart +++ b/mobile/lib/src/models/trigger.dart @@ -1,7 +1,8 @@ // ignore_for_file: hash_and_equals +import 'package:aeris/src/models/action_parameter.dart'; import 'package:flutter/material.dart'; -import 'package:aeris/src/main.dart'; +import 'package:aeris/main.dart'; import 'package:aeris/src/models/service.dart'; import 'package:aeris/src/models/action.dart' as aeris_action; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -14,11 +15,27 @@ class Trigger extends aeris_action.Action { {Key? key, required Service service, required String name, - Map parameters = const {}, + List parameters = const [], this.last}) : super(service: service, name: name, parameters: parameters); - ///TODO Constructor from DB 'Type' field + /// Unserialize + static Trigger fromJSON(Object action) { + var triggerJSON = action as Map; + Service service = + aeris_action.Action.parseServiceInName(triggerJSON['pType'] as String); + var lastTriggerField = action['lastTrigger']; + DateTime? last = lastTriggerField == null + ? null + : DateTime.parse(lastTriggerField as String); + + return Trigger( + service: service, + name: triggerJSON['pType'] as String, + last: last, + parameters: ActionParameter.fromJSON((triggerJSON['pParams'] as Map)) + ); + } String lastToString() { var context = AppLocalizations.of(Aeris.materialKey.currentContext!); @@ -32,7 +49,7 @@ class Trigger extends aeris_action.Action { /// Template trigger, used as an 'empty' trigger Trigger.template({Key? key, this.last}) - : super(service: const Service.twitter(), name: '', parameters: {}); + : super(service: Service.all()[0], name: '', parameters: []); @override // ignore: avoid_renaming_method_parameters @@ -41,7 +58,7 @@ class Trigger extends aeris_action.Action { return service.name == other.service.name && name == other.name && last == other.last && - parameters.values.toString() == other.parameters.values.toString() && - parameters.keys.toString() == other.parameters.keys.toString(); + parameters.map((e) => e.name).toString() == + other.parameters.map((e) => e.name).toString(); } } diff --git a/mobile/lib/src/models/user_service.dart b/mobile/lib/src/models/user_service.dart deleted file mode 100644 index 195bf1c..0000000 --- a/mobile/lib/src/models/user_service.dart +++ /dev/null @@ -1,28 +0,0 @@ -// Class for a service related to a user (Youtube, Gmail, ...) -import 'package:aeris/src/models/service.dart'; -import 'package:flutter/cupertino.dart'; - -class UserService { - /// Service name related to the user - final Service serviceProvider; - - /// Id of an user for this service - final String serviceAccountId; - - /// Account Username - final String accountUsername; - - /// Account Slug for this Service - final String accountSlug; - - /// Account External Token for this Service - final String userExternalToken; - - UserService( - {Key? key, - required this.serviceAccountId, - required this.accountUsername, - required this.accountSlug, - required this.userExternalToken, - required this.serviceProvider}); -} diff --git a/mobile/lib/src/providers/action_catalogue_provider.dart b/mobile/lib/src/providers/action_catalogue_provider.dart new file mode 100644 index 0000000..5290d6d --- /dev/null +++ b/mobile/lib/src/providers/action_catalogue_provider.dart @@ -0,0 +1,73 @@ +import 'package:aeris/src/models/action_parameter.dart'; +import 'package:aeris/src/models/action_template.dart'; +import 'package:aeris/src/models/service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:aeris/src/aeris_api.dart'; +import 'package:get_it/get_it.dart'; + +/// Provider class for Action listed in /about.json +class ActionCatalogueProvider extends ChangeNotifier { + /// Tells if the provers has loaded data at least once + final Map> _triggerTemplates = {}; + final Map> _reactionTemplates = {}; + Map> get triggerTemplates => _triggerTemplates; + Map> get reactionTemplates => + _reactionTemplates; + + String removeServiceFromAName(String aName) { + var words = aName.split('_'); + words.removeAt(0); + return words.join(); + } + + void reloadCatalogue() { + _triggerTemplates.clear(); + _reactionTemplates.clear(); + Service.all().forEach((element) { + _triggerTemplates.putIfAbsent(element, () => []); + _reactionTemplates.putIfAbsent(element, () => []); + }); + GetIt.I().getAbout().then((about) { + if (about.isEmpty || about == null) return; + final services = (about['server'] as Map)['services'] as List; + for (var serviceContent in services) { + Service service = Service.factory(serviceContent['name']); + for (var action in (serviceContent['actions'] as List)) { + _triggerTemplates[service]!.add( + ActionTemplate( + name: action['name'], + service: service, + description: action['description'], + parameters: (action['params'] as List).map( + (e) => ActionParameter(name: e['name'], description: e['description']) + ).toList(), + returnedValues: (action['returns'] as List).map( + (e) => ActionParameter(name: e['name'], description: e['description']) + ).toList(), + ) + ); + } + for (var reaction in serviceContent['reactions']) { + _reactionTemplates[service]!.add( + ActionTemplate( + name: reaction['name'], + service: service, + description: reaction['description'], + parameters: (reaction['params'] as List).map( + (e) => ActionParameter(name: e['name'], description: e['description']) + ).toList(), + returnedValues: (reaction['returns'] as List).map( + (e) => ActionParameter(name: e['name'], description: e['description']) + ).toList(), + ) + ); + } + } + notifyListeners(); + }); + } + + ActionCatalogueProvider() { + reloadCatalogue(); + } +} diff --git a/mobile/lib/src/providers/pipelines_provider.dart b/mobile/lib/src/providers/pipelines_provider.dart index 7944e7e..aefdbfd 100644 --- a/mobile/lib/src/providers/pipelines_provider.dart +++ b/mobile/lib/src/providers/pipelines_provider.dart @@ -10,7 +10,8 @@ class PipelineProvider extends ChangeNotifier { late PipelineCollection _pipelineCollection; /// Tells if the provers has loaded data at least once - bool initialized = false; + bool _initialized = false; + bool get initialized => _initialized; PipelineProvider() { _pipelineCollection = PipelineCollection( @@ -23,17 +24,17 @@ class PipelineProvider extends ChangeNotifier { /// Fetches the pipelines from API and put them in the collection Future fetchPipelines() { return GetIt.I().getPipelines().then((pipelines) { + _initialized = true; _pipelineCollection.pipelines = pipelines; sortPipelines(); - initialized = true; + notifyListeners(); }); } /// Adds a pipeline in the Provider - addPipeline(Pipeline newPipeline) { - initialized = true; + addPipeline(Pipeline newPipeline) async { + await GetIt.I().createPipeline(newPipeline); _pipelineCollection.pipelines.add(newPipeline); - GetIt.I().createPipeline(newPipeline); sortPipelines(); notifyListeners(); } diff --git a/mobile/lib/src/providers/services_provider.dart b/mobile/lib/src/providers/services_provider.dart new file mode 100644 index 0000000..36b8372 --- /dev/null +++ b/mobile/lib/src/providers/services_provider.dart @@ -0,0 +1,41 @@ +import 'package:aeris/src/aeris_api.dart'; +import 'package:aeris/src/models/service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:get_it/get_it.dart'; + +/// Provider used to store every Service the User is authenticated to +class ServiceProvider extends ChangeNotifier { + /// List of [Service] related to the user + List _connectedServices = []; + List get connectedServices => _connectedServices; + + /// Get the services the user is not connected to + List get availableServices => Service.all() + .where((element) => !_connectedServices.contains(element)) + .toList(); + + ServiceProvider() { + refreshServices(); + } + + /// Adds a service into the Provider + addService(Service service, String code) async { + _connectedServices.add(service); + GetIt.I() + .connectService(service, code) + .then((value) => notifyListeners()); + } + + /// Refresh services from API + refreshServices() async { + _connectedServices = await GetIt.I().getConnectedService(); + notifyListeners(); + } + + /// Removes a service from the Provider, and calls API + removeService(Service service) async { + _connectedServices.remove(service); + notifyListeners(); + await GetIt.I().disconnectService(service); + } +} diff --git a/mobile/lib/src/providers/user_services_provider.dart b/mobile/lib/src/providers/user_services_provider.dart deleted file mode 100644 index 1c941d7..0000000 --- a/mobile/lib/src/providers/user_services_provider.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'package:aeris/src/models/user_service.dart'; -import 'package:aeris/src/models/service.dart'; -import 'package:flutter/cupertino.dart'; - -/// Provider used to store every Service related to the User -class UserServiceProvider extends ChangeNotifier { - /// List of [Service] related to the user - List userServices = []; - - /// Adds a service into the Provider - addServiceForUser(UserService newService) { - userServices.add(newService); - notifyListeners(); - } - - /// Creates a new service related to the user - createUserService(Service serviceToSet, - {String accountId = "", - String accUsername = "", - String accountSlug = "", - String externalToken = ""}) { - UserService newService = UserService( - serviceAccountId: accountId, - accountUsername: accUsername, - accountSlug: accountSlug, - userExternalToken: externalToken, - serviceProvider: serviceToSet); - userServices.add(newService); - // notifyListeners(); /// TODO Get the notifyListeners method back. - } - - /// Sets a list of service into the Provider - setServiceForUser(List newServices) { - userServices = []; - userServices = newServices; - notifyListeners(); - } - - /// Modifies a service given as argument - modifyService(UserService toModify, String serviceAccountId, - String accountUsername, String accountSlug, String userExternalToken) { - for (int i = 0; i < userServices.length; i++) { - if (userServices[i].serviceProvider.name == - toModify.serviceProvider.name && - userServices[i].serviceAccountId == toModify.serviceAccountId && - userServices[i].userExternalToken == toModify.userExternalToken) { - UserService newService = UserService( - serviceProvider: userServices[i].serviceProvider, - serviceAccountId: serviceAccountId, - accountUsername: accountUsername, - accountSlug: accountSlug, - userExternalToken: userExternalToken); - userServices[i] = newService; - notifyListeners(); - return true; - } - } - return false; - } - - /// Removes a service from the Provider - removeService(UserService toRemove) { - for (UserService uService in userServices) { - if (uService.serviceProvider.name == toRemove.serviceProvider.name && - uService.serviceAccountId == toRemove.serviceAccountId && - uService.userExternalToken == toRemove.userExternalToken) { - userServices.remove(uService); - notifyListeners(); - return true; - } - } - return false; - } - - /// Clears Provider from data - clearProvider() { - userServices.clear(); - notifyListeners(); - } -} diff --git a/mobile/lib/src/views/authorization_page.dart b/mobile/lib/src/views/authorization_page.dart new file mode 100644 index 0000000..daba6ba --- /dev/null +++ b/mobile/lib/src/views/authorization_page.dart @@ -0,0 +1,28 @@ +import 'package:aeris/src/models/service.dart'; +import 'package:aeris/src/providers/services_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:loading_indicator/loading_indicator.dart'; +import 'package:provider/provider.dart'; + +class AuthorizationPage extends StatelessWidget { + const AuthorizationPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final route = ModalRoute.of(context)!.settings.name!; + final code = Uri.parse(route).queryParameters['code']!; + final serviceName = Uri.parse(route).pathSegments.last; + final service = Service.factory(serviceName); + + Provider.of(context, listen: false).addService(service, code).then((_) { + Provider.of(context, listen: false).notifyListeners(); + Navigator.pop(context); + }); + return Container( + alignment: Alignment.center, + child: LoadingIndicator( + indicatorType: Indicator.ballClipRotateMultiple, + colors: [Theme.of(context).colorScheme.secondary], + )); + } +} diff --git a/mobile/lib/src/views/create_pipeline_page.dart b/mobile/lib/src/views/create_pipeline_page.dart index 94ba951..a0bae4c 100644 --- a/mobile/lib/src/views/create_pipeline_page.dart +++ b/mobile/lib/src/views/create_pipeline_page.dart @@ -79,15 +79,19 @@ class _CreatePipelinePageState extends State { onTap: () { showAerisCardPage( context, - (_) => - SetupActionPage(action: trigger)) + (_) => SetupActionPage( + action: trigger, + parentReactions: reactions + )) .then((_) => setState(() {})); }) : ActionCard( leading: trigger.service.getLogo(logoSize: 50), - title: trigger.name, + title: trigger.displayName(), trailing: ActionCardPopupMenu( deletable: false, + parentReactions: reactions, + parentTrigger: trigger, action: trigger, then: () => setState(() {})), ), @@ -104,8 +108,10 @@ class _CreatePipelinePageState extends State { itemBuilder: (reaction) => ActionCard( key: ValueKey(reactions.indexOf(reaction)), leading: reaction.service.getLogo(logoSize: 50), - title: reaction.name, + title: reaction.displayName(), trailing: ActionCardPopupMenu( + parentTrigger: trigger == Trigger.template() ? null : trigger, + parentReactions: reactions, deletable: reactions.length > 1, action: reaction, then: () => setState(() {}), @@ -128,7 +134,10 @@ class _CreatePipelinePageState extends State { showAerisCardPage( context, (_) => SetupActionPage( - action: newreact)) + action: newreact, + parentReactions: reactions, + parentTrigger: trigger == Trigger.template() ? null : trigger, + )) .then((_) => setState(() { if (newreact != Reaction.template()) { reactions.add(newreact); diff --git a/mobile/lib/src/views/home_page.dart b/mobile/lib/src/views/home_page.dart index 49987e2..79b5f10 100644 --- a/mobile/lib/src/views/home_page.dart +++ b/mobile/lib/src/views/home_page.dart @@ -1,3 +1,4 @@ +import 'package:aeris/src/aeris_api.dart'; import 'package:aeris/src/views/create_pipeline_page.dart'; import 'package:aeris/src/views/service_page.dart'; import 'package:aeris/src/widgets/aeris_card_page.dart'; @@ -8,6 +9,7 @@ import 'package:aeris/src/widgets/aeris_page.dart'; import 'package:aeris/src/widgets/clickable_card.dart'; import 'package:aeris/src/widgets/home_page_sort_menu.dart'; import 'package:aeris/src/widgets/pipeline_card.dart'; +import 'package:get_it/get_it.dart'; import 'package:liquid_pull_to_refresh/liquid_pull_to_refresh.dart'; import 'package:provider/provider.dart'; import 'package:skeleton_loader/skeleton_loader.dart'; @@ -28,58 +30,69 @@ class _HomePageState extends State { Widget serviceActionButtons = IconButton( icon: const Icon(Icons.electrical_services), - onPressed: () => showAerisCardPage(context, (context) => const ServicePage()) - ); + onPressed: () => + showAerisCardPage(context, (context) => const ServicePage())); Widget logoutActionButton = IconButton( icon: const Icon(Icons.logout), onPressed: () => showDialog( - context: context, - builder: (BuildContext context) => WarningDialog( - message: AppLocalizations.of(context).logoutWarningMessage, - onAccept: () => Navigator.of(context).popAndPushNamed('/'), //TODO logout - warnedAction: AppLocalizations.of(context).logout - ) - ), + context: context, + builder: (BuildContext context) => WarningDialog( + message: AppLocalizations.of(context).logoutWarningMessage, + onAccept: () { + GetIt.I().stopConnection(); + Navigator.of(context).popAndPushNamed('/'); + }, + warnedAction: AppLocalizations.of(context).logout)), ); return Consumer( - builder: (context, provider, _) => AerisPage( - floatingActionButton: FloatingActionButton( - onPressed: () => showAerisCardPage(context, (_) => const CreatePipelinePage()), - backgroundColor: Theme.of(context).colorScheme.secondary, - elevation: 10, - child: const Icon(Icons.add), - ), - actions: [ - HomePageSortMenu( - collectionProvider: provider, - ), - serviceActionButtons, - logoutActionButton - ], - body: provider.initialized == false - ? ListView(physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(bottom: 20, top: 20, left: 10, right: 10), - children: [SkeletonLoader( - builder: ClickableCard(onTap:(){}, body: const SizedBox(height: 80)), - items: 10, - highlightColor: Theme.of(context).colorScheme.secondary - )]) - : LiquidPullToRefresh( - borderWidth: 2, - animSpeedFactor: 3, - color: Colors.transparent, - showChildOpacityTransition: false, - onRefresh: () => provider.fetchPipelines() - .then((_) => setState(() {})), // refresh callback - child: ListView.builder( - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.only(bottom: 20, top: 20, left: 10, right: 10), - controller: listController, - itemCount: provider.pipelineCount, - itemBuilder: (BuildContext context, int index) => - PipelineCard(pipeline: provider.getPipelineAt(index), - ), - )), - )); + builder: (context, provider, _) => AerisPage( + floatingActionButton: FloatingActionButton( + onPressed: () => showAerisCardPage( + context, (_) => const CreatePipelinePage()), + backgroundColor: Theme.of(context).colorScheme.secondary, + elevation: 10, + child: const Icon(Icons.add), + ), + actions: [ + HomePageSortMenu( + collectionProvider: provider, + ), + serviceActionButtons, + logoutActionButton + ], + body: provider.initialized == false + ? ListView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.only( + bottom: 20, top: 20, left: 10, right: 10), + children: [ + SkeletonLoader( + builder: ClickableCard( + onTap: () {}, + body: const SizedBox(height: 80)), + items: 10, + highlightColor: + Theme.of(context).colorScheme.secondary) + ]) + : LiquidPullToRefresh( + borderWidth: 2, + animSpeedFactor: 3, + color: Colors.transparent, + showChildOpacityTransition: false, + onRefresh: () => provider + .fetchPipelines() + .then((_) => setState(() {})), // refresh callback + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only( + bottom: 20, top: 20, left: 10, right: 10), + controller: listController, + itemCount: provider.pipelineCount, + itemBuilder: (BuildContext context, int index) => + PipelineCard( + pipeline: provider.getPipelineAt(index), + ), + )), + )); } } diff --git a/mobile/lib/src/views/login_page.dart b/mobile/lib/src/views/login_page.dart index 68ffd82..2078fe0 100644 --- a/mobile/lib/src/views/login_page.dart +++ b/mobile/lib/src/views/login_page.dart @@ -1,13 +1,10 @@ -import 'package:aeris/src/main.dart'; +import 'package:aeris/src/aeris_api.dart'; +import 'package:aeris/main.dart'; import 'package:aeris/src/widgets/aeris_page.dart'; import 'package:flutter_login/flutter_login.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -const users = { - 'dribbble@gmail.com': '12345', - 'hunter@gmail.com': 'hunter', -}; +import 'package:get_it/get_it.dart'; /// Login Page Widget class LoginPage extends StatelessWidget { @@ -17,39 +14,24 @@ class LoginPage extends StatelessWidget { Duration get loginDuration => const Duration(milliseconds: 2500); /// Called when user clicks on [FlutterLogin] widget 'login' button - Future _authUser(LoginData data) { - debugPrint('Name: ${data.name}, Password: ${data.password}'); - return Future.delayed(loginDuration).then((_) { - if (!users.containsKey(data.name)) { - return AppLocalizations.of(Aeris.materialKey.currentContext!) - .usernameOrPasswordIncorrect; - } - if (users[data.name] != data.password) { - return AppLocalizations.of(Aeris.materialKey.currentContext!) - .usernameOrPasswordIncorrect; - } - return null; - }); + Future _authUser(LoginData data) async { + bool connected = + await GetIt.I().createConnection(data.name, data.password); + if (!connected) { + return AppLocalizations.of(Aeris.materialKey.currentContext!) + .usernameOrPasswordIncorrect; + } + return null; } /// Opens signup page of [FlutterLogin] widget - Future _signupUser(SignupData data) { - debugPrint('Signup Name: ${data.name}, Password: ${data.password}'); - return Future.delayed(loginDuration).then((_) { - return null; - }); - } - - /// Opens user password recovery page - Future _recoverPassword(String name) { - debugPrint('Name: $name'); - return Future.delayed(loginDuration).then((_) { - if (!users.containsKey(name)) { - return AppLocalizations.of(Aeris.materialKey.currentContext!) - .userDoesNotExist; - } - return null; - }); + Future _signupUser(SignupData data) async { + bool connected = + await GetIt.I().signUpUser(data.name!, data.password!); + if (connected == false) { + return AppLocalizations.of(Aeris.materialKey.currentContext!).errorOnSignup; + } + return null; } @override @@ -59,16 +41,21 @@ class LoginPage extends StatelessWidget { body: FlutterLogin( disableCustomPageTransformer: true, logo: const AssetImage("assets/logo.png"), - onRecoverPassword: _recoverPassword, + hideForgotPasswordButton: true, + onRecoverPassword: (_) => null, theme: LoginTheme( pageColorLight: Colors.transparent, pageColorDark: Colors.transparent, primaryColor: Theme.of(context).colorScheme.primary), onLogin: _authUser, onSignup: _signupUser, + userType: LoginUserType.name, + userValidator: (input) { + if (input == null || input.trim().length < 4) return "Must be at least 4 chars long"; + return null; + }, onSubmitAnimationCompleted: () { - Navigator.of(context).popUntil((route) => route.isFirst); - Navigator.of(context).popAndPushNamed("/home"); + Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); })); } } diff --git a/mobile/lib/src/views/pipeline_detail_page.dart b/mobile/lib/src/views/pipeline_detail_page.dart index 0936c2f..4c5aeca 100644 --- a/mobile/lib/src/views/pipeline_detail_page.dart +++ b/mobile/lib/src/views/pipeline_detail_page.dart @@ -95,7 +95,12 @@ class _PipelineDetailPageState extends State { onTap: () { Reaction newreaction = Reaction.template(); showAerisCardPage( - context, (_) => SetupActionPage(action: newreaction)) + context, (_) => SetupActionPage( + action: newreaction, + parentTrigger: pipeline.trigger, + parentReactions: pipeline.reactions, + ) + ) .then((r) { if (newreaction != Reaction.template()) { setState(() { @@ -135,9 +140,11 @@ class _PipelineDetailPageState extends State { style: const TextStyle(fontWeight: FontWeight.w500)), ActionCard( leading: pipeline.trigger.service.getLogo(logoSize: 50), - title: pipeline.trigger.name, + title: pipeline.trigger.displayName(), trailing: ActionCardPopupMenu( deletable: false, + parentTrigger: pipeline.trigger, + parentReactions: pipeline.reactions, action: pipeline.trigger, then: () { setState(() {}); @@ -152,8 +159,10 @@ class _PipelineDetailPageState extends State { itemBuilder: (reaction) => ActionCard( key: ValueKey(pipeline.reactions.indexOf(reaction)), leading: reaction.service.getLogo(logoSize: 50), - title: reaction.name, + title: reaction.displayName(), trailing: ActionCardPopupMenu( + parentTrigger: pipeline.trigger, + parentReactions: pipeline.reactions, deletable: pipeline.reactions.length > 1, action: reaction, then: () { diff --git a/mobile/lib/src/views/service_page.dart b/mobile/lib/src/views/service_page.dart index 4b7a719..0489442 100644 --- a/mobile/lib/src/views/service_page.dart +++ b/mobile/lib/src/views/service_page.dart @@ -1,41 +1,36 @@ -import 'package:aeris/src/aeris_api.dart'; import 'package:flutter/material.dart'; -import 'package:aeris/src/models/pipeline.dart'; -import 'package:aeris/src/models/reaction.dart'; import 'package:aeris/src/models/service.dart'; import 'package:aeris/src/providers/pipelines_provider.dart'; -import 'package:aeris/src/providers/user_services_provider.dart'; +import 'package:aeris/src/providers/services_provider.dart'; import 'package:aeris/src/widgets/action_card.dart'; import 'package:aeris/src/widgets/aeris_card_page.dart'; import 'package:aeris/src/widgets/warning_dialog.dart'; -import 'package:get_it/get_it.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:url_launcher/url_launcher.dart'; ///Page listing connected & available services class ServicePage extends StatelessWidget { const ServicePage({Key? key}) : super(key: key); - List getServiceGroup(String groupName, Icon trailingIcon, + List getServiceGroup(List services, String groupName, Icon trailingIcon, void Function(Service) onTap, BuildContext context) { - UserServiceProvider uServicesProvider = - Provider.of(context); - + if (services.isEmpty) return []; return [ Text( "$groupName:", style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 10), - for (var service in uServicesProvider.userServices) + for (var service in services) ActionCard( - leading: service.serviceProvider.getLogo(logoSize: 50), - title: service.serviceProvider.name, + leading: service.getLogo(logoSize: 50), + title: service.name, trailing: IconButton( splashColor: trailingIcon.color!.withAlpha(100), splashRadius: 20, icon: trailingIcon, - onPressed: () => onTap(service.serviceProvider), + onPressed: () => onTap(service), )), const SizedBox(height: 30), ]; @@ -43,69 +38,45 @@ class ServicePage extends StatelessWidget { @override Widget build(BuildContext context) { - List services = const [ - Service.discord(), - Service.gmail(), - Service.github(), - Service.youtube(), - Service.twitter(), - Service.spotify() - ]; - UserServiceProvider uServiceProvider = - Provider.of(context, listen: false); - uServiceProvider.clearProvider(); - for (var service in services) { - uServiceProvider.createUserService(service); - } - - return Consumer( - builder: (context, provider, _) => AerisCardPage( + return Consumer( + builder: (context, serviceProvider, _) => Consumer( + builder: (context, pipelineProvider, _) => AerisCardPage( body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...[ - Align( - alignment: Alignment.center, - child: Text(AppLocalizations.of(context).services, style: const TextStyle(fontSize: 25)), - ), - const SizedBox(height: 60) - ], - ...getServiceGroup( - AppLocalizations.of(context).connected, - const Icon(Icons.delete, color: Colors.red), - (Service service) => showDialog( - context: context, - builder: (BuildContext context) => WarningDialog( - message: AppLocalizations.of(context) - .disconnectServiceWarningMessage, - onAccept: () => { - provider.removePipelinesWhere((Pipeline pipeline) { - if (pipeline.trigger.service == service) { - return true; - } - if (pipeline.reactions - .where((Reaction react) => - react.service == service) - .isNotEmpty) { - return true; - } - return false; - }), - GetIt.I().disconnectService(service) - }, - warnedAction: - AppLocalizations.of(context).disconnect)), - context), - ...getServiceGroup( - AppLocalizations.of(context).available, - const Icon(Icons.connect_without_contact, - color: Colors.green), - (Service service) => - print("Connected") /* TODO open page to connect service*/, - context), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...[ + Align( + alignment: Alignment.center, + child: Text(AppLocalizations.of(context).services, + style: const TextStyle(fontSize: 25)), + ), + const SizedBox(height: 60) ], - ), + ...getServiceGroup( + serviceProvider.connectedServices, + AppLocalizations.of(context).connected, + const Icon(Icons.delete, color: Colors.red), + (Service service) => showDialog( + context: context, + builder: (BuildContext context) => WarningDialog( + message: AppLocalizations.of(context) + .disconnectServiceWarningMessage, + onAccept: () => serviceProvider + .removeService(service) + .then((_) => pipelineProvider.fetchPipelines()), + warnedAction: AppLocalizations.of(context).disconnect)), + context), + ...getServiceGroup( + serviceProvider.availableServices, + AppLocalizations.of(context).available, + const Icon(Icons.connect_without_contact, color: Colors.green), + (Service service) { + launch(Uri.parse(service.authUrl).toString(), forceSafariVC: false); + }, + context), + ], ), - ); + ), + )); } } diff --git a/mobile/lib/src/views/setup_action_page.dart b/mobile/lib/src/views/setup_action_page.dart index 52389b9..49f006d 100644 --- a/mobile/lib/src/views/setup_action_page.dart +++ b/mobile/lib/src/views/setup_action_page.dart @@ -1,5 +1,7 @@ +import 'package:aeris/src/models/action_parameter.dart'; import 'package:aeris/src/models/action_template.dart'; import 'package:aeris/src/aeris_api.dart'; +import 'package:aeris/src/models/reaction.dart'; import 'package:aeris/src/models/trigger.dart'; import 'package:flutter/material.dart'; import 'package:aeris/src/models/action.dart' as aeris; @@ -13,10 +15,15 @@ import 'package:skeleton_loader/skeleton_loader.dart'; ///Page to setup an action class SetupActionPage extends StatefulWidget { - const SetupActionPage({Key? key, required this.action}) : super(key: key); + const SetupActionPage({Key? key, required this.action, required this.parentReactions, this.parentTrigger}) : super(key: key); /// Action to setup final aeris.Action action; + /// Trigger of Parent of the action to setup + final Trigger? parentTrigger; + + /// reactions of Parent of the action to setup + final List parentReactions; @override State createState() => _SetupActionPageState(); @@ -30,9 +37,7 @@ class _SetupActionPageState extends State { void initState() { super.initState(); serviceState = widget.action.service; - GetIt.I().getActionsFor(serviceState!, widget.action).then((actions) => setState(() { - availableActions = actions; - })); + availableActions = GetIt.I().getActionsFor(serviceState!, widget.action); } @override @@ -43,12 +48,9 @@ class _SetupActionPageState extends State { elevation: 8, underline: Container(), onChanged: (service) { - GetIt.I().getActionsFor(service!, widget.action).then((actions) => setState(() { - availableActions = actions; - })); setState(() { serviceState = service; - availableActions = []; + availableActions = GetIt.I().getActionsFor(service!, widget.action); }); }, items: Service.all().map>((Service service) { @@ -66,6 +68,9 @@ class _SetupActionPageState extends State { }).toList(), ); + var cardShape = const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10))); + return AerisCardPage( body: Padding( padding: const EdgeInsets.only(bottom: 20, left: 10, right: 10), @@ -98,41 +103,54 @@ class _SetupActionPageState extends State { ), ], ), - const SizedBox(height: 30), + const SizedBox(height: 20), + Text(AppLocalizations.of(context).paramInheritTip), + const SizedBox(height: 20), if (availableActions == null) SkeletonLoader( - builder: const Card(child: SizedBox(height: 40), elevation: 5), - items: 10, + builder: Card(shape: cardShape, child: const SizedBox(height: 40), elevation: 5), + items: 15, highlightColor: Theme.of(context).colorScheme.secondary ) else - ...[for (aeris.Action availableAction in availableActions!) + ...[for (ActionTemplate availableAction in availableActions!) Card( elevation: 5, - child: ExpandablePanel( + shape: cardShape, + child: ExpandableNotifier( + child: ScrollOnExpand(child: ExpandablePanel( header: Padding( padding: const EdgeInsets.only(left: 30, top: 20, bottom: 20), - child: Text(availableAction.name, + child: Text(availableAction.displayName(), style: const TextStyle(fontSize: 15))), collapsed: Container(), expanded: Padding( padding: const EdgeInsets.all(20), child: ActionForm( + reactionsCandidates: widget.parentReactions, + triggerCandidate: widget.parentTrigger, + candidate: widget.action, + key: Key("${availableAction.name}${availableAction.description}${availableAction.service}"), + description: availableAction.description!, name: availableAction.name, - parametersNames: - availableAction.parameters.keys.toList(), - initValues: widget.action.name == availableAction.name - && availableAction.service.name == widget.action.service.name - ? widget.action.parameters : const {}, + parameters: availableAction.parameters.map((param) { + if (widget.action.service.name == serviceState!.name && widget.action.name == availableAction.name) { + var previousParams = widget.action.parameters.where((element) => element.name == param.name); + if (previousParams.isNotEmpty) { + param.value = previousParams.first.value; + } + } + return param; + }).toList(), onValidate: (parameters) { widget.action.service = serviceState!; - widget.action.parameters = parameters; + widget.action.parameters = ActionParameter.fromJSON(parameters); widget.action.name = availableAction.name; Navigator.of(context).pop(); }), )), - ), + ))), const SizedBox(height: 10) ] ], diff --git a/mobile/lib/src/views/startup_page.dart b/mobile/lib/src/views/startup_page.dart index 9e84589..5f9b8c1 100644 --- a/mobile/lib/src/views/startup_page.dart +++ b/mobile/lib/src/views/startup_page.dart @@ -1,4 +1,5 @@ import 'package:aeris/src/aeris_api.dart'; +import 'package:aeris/src/widgets/setup_api_route.dart'; import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; @@ -15,11 +16,30 @@ class StartupPage extends StatefulWidget { } class _StartupPageState extends State { + bool connected = false; + @override + void initState() { + super.initState(); + GetIt.I().getAbout().then((value) { + setState(() { + connected = value.isNotEmpty; + }); + }); + } + @override Widget build(BuildContext context) { - bool isConnected = GetIt.I().connected; + bool isConnected = GetIt.I().isConnected; return AerisPage( displayAppbar: false, + floatingActionButton: SetupAPIRouteButton( + connected: connected, + onSetup: () => GetIt.I().getAbout().then((value) { + setState(() { + connected = value.isNotEmpty; + }); + }) + ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -48,13 +68,13 @@ class _StartupPageState extends State { textStyle: const TextStyle(fontSize: 20), primary: Theme.of(context).colorScheme.secondary, ), - onPressed: () { + onPressed: connected ? () { if (isConnected) { - Navigator.of(context).popAndPushNamed('/home'); + Navigator.of(context).pushNamedAndRemoveUntil('/home', (route) => false); } else { Navigator.of(context).pushNamed('/login'); } - }, + } : null, child: Tooltip( message: 'Connexion', child: Text(AppLocalizations.of(context).connect) diff --git a/mobile/lib/src/widgets/action_card_popup_menu.dart b/mobile/lib/src/widgets/action_card_popup_menu.dart index 89d5c6c..e3ed10d 100644 --- a/mobile/lib/src/widgets/action_card_popup_menu.dart +++ b/mobile/lib/src/widgets/action_card_popup_menu.dart @@ -1,3 +1,5 @@ +import 'package:aeris/src/models/reaction.dart'; +import 'package:aeris/src/models/trigger.dart'; import 'package:aeris/src/widgets/aeris_card_page.dart'; import 'package:flutter/material.dart'; import 'package:aeris/src/views/setup_action_page.dart'; @@ -11,6 +13,8 @@ class ActionCardPopupMenu extends StatelessWidget { ActionCardPopupMenu({ Key? key, required this.action, + this.parentTrigger, + required this.parentReactions, required this.then, required this.deletable, this.onDelete, @@ -22,6 +26,10 @@ class ActionCardPopupMenu extends StatelessWidget { /// Selected Action final aeris.Action action; + /// Trigger of the Parent of the action + final Trigger? parentTrigger; + /// Trigger of the Parent of the action + final List parentReactions; /// Function to trigger once the Edit menu is closed final void Function() then; @@ -46,15 +54,18 @@ class ActionCardPopupMenu extends StatelessWidget { icon: Icons.settings, title: AppLocalizations.of(context).modify, value: () => showAerisCardPage( - context, (_) => SetupActionPage(action: action)).then((_) => then()) - ), + context, (_) => SetupActionPage( + action: action, + parentTrigger: parentTrigger, + parentReactions: parentReactions, + )) + .then((_) => then())), AerisPopupMenuItem( - context: context, - icon: Icons.delete, - title: AppLocalizations.of(context).delete, - value: onDelete, - enabled: deletable - ), + context: context, + icon: Icons.delete, + title: AppLocalizations.of(context).delete, + value: onDelete, + enabled: deletable), ]); } } diff --git a/mobile/lib/src/widgets/action_form.dart b/mobile/lib/src/widgets/action_form.dart index 3370993..5e7b308 100644 --- a/mobile/lib/src/widgets/action_form.dart +++ b/mobile/lib/src/widgets/action_form.dart @@ -1,16 +1,43 @@ +import 'package:aeris/src/models/action_parameter.dart'; +import 'package:aeris/src/models/action_template.dart'; +import 'package:aeris/src/models/reaction.dart'; +import 'package:aeris/src/models/action.dart' as aeris; +import 'package:aeris/src/models/trigger.dart'; +import 'package:aeris/src/providers/action_catalogue_provider.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_form_builder/flutter_form_builder.dart'; import 'package:form_builder_validators/form_builder_validators.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; +import 'package:recase/recase.dart'; +import 'package:tuple/tuple.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; + +class Suggestion extends Tuple3 { + Suggestion(int item1, ActionParameter item2, ActionTemplate item3) : super(item1, item2, item3); + + // Overriding show method + + @override + String toString() { + return "${item2.name}@$item1"; + } +} /// Form for an action class ActionForm extends StatefulWidget { /// Name of the action final String name; - /// Names of the parameters - final List parametersNames; - /// Initial values of the fields - final Map initValues; + /// List of parameters, 'values' are used as default values + final List parameters; + /// What the action does + final String description; + + /// The Action that will be eventually filled by the form + final aeris.Action candidate; + /// The trigger candidate in the parent form page + final Trigger? triggerCandidate; + /// The trigger candidate in the parent form page + final List reactionsCandidates; /// On validate callback final void Function(Map) onValidate; @@ -18,9 +45,13 @@ class ActionForm extends StatefulWidget { const ActionForm( {Key? key, required this.name, - required this.parametersNames, + required this.description, + required this.parameters, required this.onValidate, - this.initValues = const {}}) + required this.candidate, + this.triggerCandidate, + required this.reactionsCandidates, + }) : super(key: key); @override @@ -28,32 +59,91 @@ class ActionForm extends StatefulWidget { } class _ActionFormState extends State { - final _formKey = GlobalKey(); + final _formKey = GlobalKey(); + + List getSuggestions(String pattern, ActionCatalogueProvider catalogue) { + List suggestions = []; + if (pattern.endsWith("{") == false) return suggestions; + if (widget.candidate is Trigger) return suggestions; + if (widget.triggerCandidate != null) { + Trigger trigger = widget.triggerCandidate!; + var triggerTemplate = catalogue.triggerTemplates[trigger.service]!.firstWhere( + (element) => element.name == trigger.name + ); + for (var parameter in triggerTemplate.returnedValues) { + suggestions.add(Suggestion(0, parameter, triggerTemplate)); + } + } + int index = 1; + int indexOfCandidate = widget.reactionsCandidates.indexOf(widget.candidate as Reaction); + for (var reactionCandidate in widget.reactionsCandidates) { + if (index == indexOfCandidate + 1) break; + var reactionTemplate = catalogue.reactionTemplates[reactionCandidate.service]!.firstWhere( + (element) => element.name == reactionCandidate.name + ); + for (var parameter in reactionTemplate.returnedValues) { + suggestions.add(Suggestion(index, parameter, reactionTemplate)); + } + index++; + } + return suggestions; + } + + Map values = {}; @override Widget build(BuildContext context) { - return FormBuilder( + return Consumer( + builder: (__, catalogue, _) => Form( key: _formKey, child: Column( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - ...widget.parametersNames.map((name) => FormBuilderTextField( - initialValue: (widget.initValues.containsKey(name)) ? widget.initValues[name] as String : null, - name: name, - decoration: InputDecoration( - labelText: name, - ), - validator: FormBuilderValidators.compose([ - FormBuilderValidators.required(context), - ]), - keyboardType: (widget.initValues.containsKey(name)) && widget.initValues[name] is int ? TextInputType.number : null, - )), + Text(widget.description, textAlign: TextAlign.left, style: TextStyle(color: Theme.of(context).colorScheme.onSurface)), + ...widget.parameters.map((param) { + final textEditingController = TextEditingController(text: values[param.name] ?? param.value?.toString()); + return TypeAheadFormField( + key: Key(param.description), + textFieldConfiguration: TextFieldConfiguration( + autofocus: true, + controller: textEditingController, + enableSuggestions: widget.candidate is Reaction, + decoration: InputDecoration( + labelText: ReCase(param.name).titleCase, + helperText: param.description + ), + ), + onSaved: (value) { + values[param.name] = value!; + }, + suggestionsBoxDecoration: const SuggestionsBoxDecoration( + elevation: 6, + borderRadius: BorderRadius.all(Radius.circular(10)) + ), + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(context), + ]), + hideOnEmpty: true, + suggestionsCallback: (suggestion) => getSuggestions(suggestion, catalogue), + onSuggestionSelected: (suggestion) { + textEditingController.text += suggestion.toString(); + textEditingController.text += "}"; + values[param.name] = textEditingController.text; + }, + itemBuilder: (context, suggestion) => ListTile( + isThreeLine: true, + dense: true, + leading: suggestion.item3.service.getLogo(logoSize: 30), + title: Text(suggestion.item2.name), + subtitle: Text("${suggestion.item2.description}, from '${suggestion.item3.displayName()}'") + ));}), ...[ ElevatedButton( child: Text(AppLocalizations.of(context).save), onPressed: () { - _formKey.currentState!.save(); if (_formKey.currentState!.validate()) { - widget.onValidate(_formKey.currentState!.value.map((key, value) => MapEntry(key, value))); + _formKey.currentState!.save(); + widget.onValidate(values); } }, ), @@ -61,6 +151,6 @@ class _ActionFormState extends State { ] ] ) - ); + )); } } diff --git a/mobile/lib/src/widgets/home_page_sort_menu.dart b/mobile/lib/src/widgets/home_page_sort_menu.dart index 24af014..f33b4ef 100644 --- a/mobile/lib/src/widgets/home_page_sort_menu.dart +++ b/mobile/lib/src/widgets/home_page_sort_menu.dart @@ -50,7 +50,6 @@ class HomePageSortMenu extends StatelessWidget { value: !split), ], onSelected: (sortingMethod) { - /// TODO: not clean if (sortingMethod is bool) { collectionProvider.splitDisabled = sortingMethod; } else { diff --git a/mobile/lib/src/widgets/reorderable_reaction_cards_list.dart b/mobile/lib/src/widgets/reorderable_reaction_cards_list.dart index 87a0fc5..1abf198 100644 --- a/mobile/lib/src/widgets/reorderable_reaction_cards_list.dart +++ b/mobile/lib/src/widgets/reorderable_reaction_cards_list.dart @@ -3,14 +3,14 @@ import 'package:flutter/widgets.dart'; import 'package:reorderables/reorderables.dart'; class ReorderableReactionCardsList extends StatefulWidget { - ReorderableReactionCardsList( + const ReorderableReactionCardsList( {Key? key, required this.onReorder, required this.reactionList, required this.itemBuilder}) : super(key: key); - List reactionList; + final List reactionList; // Callback when a list has been reordered final void Function() onReorder; diff --git a/mobile/lib/src/widgets/setup_api_route.dart b/mobile/lib/src/widgets/setup_api_route.dart new file mode 100644 index 0000000..d6e6e12 --- /dev/null +++ b/mobile/lib/src/widgets/setup_api_route.dart @@ -0,0 +1,118 @@ +import 'package:aeris/src/aeris_api.dart'; +import 'package:aeris/src/providers/action_catalogue_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:get_it/get_it.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +/// Floating Action button to access the setup API route modal +class SetupAPIRouteButton extends StatefulWidget { + ///Can the app access the api with the current baseRoute? + bool connected; + void Function() onSetup; + SetupAPIRouteButton( + {Key? key, required this.connected, required this.onSetup}) + : super(key: key); + + @override + State createState() => _SetupAPIRouteButtonState(); +} + +class _SetupAPIRouteButtonState extends State { + @override + Widget build(BuildContext context) { + return FloatingActionButton( + onPressed: () => showDialog( + context: context, builder: (_) => const SetupAPIRouteModal()) + .then((_) => widget.onSetup()), + backgroundColor: Theme.of(context).colorScheme.secondary, + elevation: 10, + child: Icon(widget.connected == true + ? Icons.wifi + : Icons.signal_cellular_connected_no_internet_0_bar_sharp), + ); + } +} + +/// Modal to setup route to connect to api +class SetupAPIRouteModal extends StatefulWidget { + const SetupAPIRouteModal({Key? key}) : super(key: key); + + @override + State createState() => _SetupAPIRouteModalState(); +} + +class _SetupAPIRouteModalState extends State { + bool? connected; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + GetIt.I().getAbout().then((value) { + setState(() { + connected = value.isNotEmpty; + }); + }); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(AppLocalizations.of(context).setupAPIRoute), + content: FormBuilder( + key: _formKey, + child: FormBuilderTextField( + initialValue: GetIt.I().baseRoute, + name: "route", + validator: FormBuilderValidators.required(context), + decoration: InputDecoration( + labelText: AppLocalizations.of(context).routeToApi, + helperText: "Ex: http://host:port")), + ), + actionsAlignment: MainAxisAlignment.spaceEvenly, + actions: [ + ElevatedButton( + child: Text(AppLocalizations.of(context).tryToConnect), + onPressed: () { + _formKey.currentState!.save(); + if (_formKey.currentState!.validate()) { + var route = _formKey.currentState!.value['route']; + if (Uri.tryParse(route) == null) { + setState(() => connected = false); + } else { + final oldRoute = GetIt.I().baseRoute; + GetIt.I().baseRoute = route; + setState(() { + connected = null; + }); + GetIt.I().getAbout().then((value) { + setState(() { + connected = value.isNotEmpty; + }); + }, onError: (_) => GetIt.I().baseRoute = oldRoute); + } + } + }, + ), + ElevatedButton( + child: Text(connected == null + ? AppLocalizations.of(context).loading + : connected == true + ? AppLocalizations.of(context).save + : AppLocalizations.of(context).invalidUrl), + onPressed: connected == true + ? () { + GetIt.I() + .setString('api', GetIt.I().baseRoute); + Provider.of(context, listen: false).reloadCatalogue(); + Navigator.of(context).pop(); + } + : null, + ) + ]); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7849237..5b0d21c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -181,6 +181,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "7.1.0" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + url: "https://pub.dartlang.org" + source: hosted + version: "5.2.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -233,6 +254,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_typeahead: + dependency: "direct main" + description: + name: flutter_typeahead + url: "https://pub.dartlang.org" + source: hosted + version: "3.2.4" flutter_web_plugins: dependency: transitive description: flutter @@ -260,7 +288,7 @@ packages: source: hosted version: "7.2.0" http: - dependency: transitive + dependency: "direct main" description: name: http url: "https://pub.dartlang.org" @@ -308,6 +336,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.0.1" + loading_indicator: + dependency: "direct main" + description: + name: loading_indicator + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.3" matcher: dependency: transitive description: @@ -358,7 +393,7 @@ packages: source: hosted version: "1.8.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider url: "https://pub.dartlang.org" @@ -434,6 +469,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.2" + positioned_tap_detector_2: + dependency: "direct main" + description: + name: positioned_tap_detector_2 + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" process: dependency: transitive description: @@ -476,6 +518,62 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.27.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.13" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.11" + shared_preferences_ios: + dependency: transitive + description: + name: shared_preferences_ios + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + shared_preferences_macos: + dependency: transitive + description: + name: shared_preferences_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" shimmer: dependency: transitive description: @@ -483,6 +581,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.0.0" + simple_autocomplete_formfield: + dependency: "direct main" + description: + name: simple_autocomplete_formfield + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" skeleton_loader: dependency: "direct main" description: @@ -558,6 +663,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.8" + textfield_state: + dependency: transitive + description: + name: textfield_state + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" typed_data: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a088dff..6ce185b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -53,6 +53,14 @@ dependencies: skeleton_loader: ^2.0.0+4 drag_and_drop_lists: ^0.3.2+2 reorderables: ^0.4.2 + path_provider: ^2.0.9 + http: ^0.13.4 + tuple: ^2.0.0 + loading_indicator: ^3.0.3 + shared_preferences: ^2.0.13 + flutter_typeahead: ^3.2.4 + simple_autocomplete_formfield: ^0.3.0 + positioned_tap_detector_2: ^1.0.4 dev_dependencies: flutter_launcher_icons: "^0.9.2" diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 5b48bd2..a1aa3f6 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:aeris/src/main.dart'; +import 'package:aeris/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async {