Merge branch 'master' of github.com:AnonymusRaccoon/Aeris into multilng-about-json

This commit is contained in:
Clément Le Bihan
2022-03-04 16:32:57 +01:00
48 changed files with 1303 additions and 509 deletions

View File

@@ -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=

View File

@@ -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

View File

@@ -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."

View File

@@ -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",

View File

@@ -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

View File

@@ -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:

View File

@@ -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}

View File

@@ -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

View File

@@ -1,7 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.aeris.mobile">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="Aeris"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Deep linking -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="arthichaud.me" />
<data android:scheme="https" android:host="arthichaud.me"/>
<data android:scheme="aeris" android:host="arthichaud.me"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View File

@@ -25,6 +25,16 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Deep linking -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" android:host="arthichaud.me" />
<data android:scheme="https" android:host="arthichaud.me"/>
<data android:scheme="aeris" android:host="arthichaud.me"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

@@ -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

View File

@@ -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 = "<group>"; };
0C6C3D7227D0D7C100B12C20 /* RunnerDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerDebug.entitlements; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
@@ -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;

View File

@@ -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"
}

View File

@@ -47,5 +47,20 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>arthichaud.me</string>
<key>CFBundleURLSchemes</key>
<array>
<string>aeris</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -45,5 +45,20 @@
<false/>
<key>UIStatusBarHidden</key>
<false/>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>arthichaud.me</string>
<key>CFBundleURLSchemes</key>
<array>
<string>aeris</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.default-data-protection</key>
<string>NSFileProtectionComplete</string>
</dict>
</plist>

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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<SharedPreferences>(prefs);
AerisAPI interface = AerisAPI();
GetIt.I.registerSingleton<AerisAPI>(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) =>
transitionsBuilder: (context, animation, secondaryAnimation,
child) =>
SlideTransition(
child: child,
position: animation.drive(
Tween(
begin: const Offset(1.0, 0.0),
end: Offset.zero
)
)
)
);
position: animation.drive(Tween(
begin: const Offset(1.0, 0.0), end: Offset.zero))));
});
}
}

View File

@@ -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<Pipeline> 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<SharedPreferences>().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<void> createPipeline(Pipeline newPipeline) async {
///TODO Send Pipeline to API
fakeAPI.add(newPipeline);
await Future.delayed(const Duration(seconds: 2));
/// 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<bool> 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<bool> 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<SharedPreferences>().setString('jwt', jwt);
_connected = true;
_jwt = jwt;
} catch (e) {
return false;
}
return true;
}
/// Create an API connection using previously created credentials
Future<void> restoreConnection() async {
try {
final cred = GetIt.I<SharedPreferences>().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<void> stopConnection() async {
await GetIt.I<SharedPreferences>().remove('jwt');
_connected = false;
}
///Get /about.json
Future<Map<String, dynamic>> 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<bool> 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<void> removePipeline(Pipeline pipeline) async {
///TODO Send delete request to API
fakeAPI.remove(pipeline);
await Future.delayed(const Duration(seconds: 2));
return;
Future<bool> removePipeline(Pipeline pipeline) async {
var res = await _requestAPI(
'/workflow/${pipeline.id}', AerisAPIRequestType.delete, null);
return res.ok;
}
Future<void> 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<bool> 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<List<Pipeline>> 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<List<Service>> 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<void> disconnectService(Service service) async {
///TODO disconnect service from user
await Future.delayed(const Duration(seconds: 2));
return;
Future<bool> disconnectService(Service service) async {
var res = await _requestAPI('/auth/${service.name.toLowerCase()}',
AerisAPIRequestType.delete, null);
return res.ok;
}
Future<List<ActionTemplate>> getActionsFor(
Service service, Action action) async {
await Future.delayed(const Duration(seconds: 3));
/// Connects the user from the service
Future<bool> 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<ActionTemplate> getActionsFor(Service service, aeris.Action action) {
final catalogue =
Aeris.materialKey.currentContext?.read<ActionCatalogueProvider>();
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<http.Response> _requestAPI(
String route, AerisAPIRequestType requestType, Object? body) async {
final Map<String, String> 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'})
];
}
}

View File

@@ -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<String, Object> parameters;
List<ActionParameter> 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;
}
}

View File

@@ -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<String, dynamic> toJson() => MapEntry(name, value);
static List<ActionParameter> fromJSON(Map<String, dynamic> params) {
List<ActionParameter> actionParameters = [];
params.forEach((key, value) =>
actionParameters.add(ActionParameter(name: key, value: value)));
return actionParameters;
}
}

View File

@@ -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<ActionParameter> returnedValues;
ActionTemplate(
{Key? key,
required Service service,
required String name,
Map<String, Object> parameters = const {}})
: super(service: service, name: name, parameters: parameters);
required String description,
this.returnedValues = const [],
List<ActionParameter> parameters = const []})
: super(service: service, name: name, parameters: parameters, description: description);
}

View File

@@ -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<Reaction> reactions;
@@ -29,4 +28,34 @@ class Pipeline {
required this.enabled,
required this.trigger,
required this.reactions});
/// Unserialize Pipeline from JSON
static Pipeline fromJSON(Map<String, dynamic> data) {
var action = data['action'] as Map<String, dynamic>;
var reactions = data['reactions'] as List<dynamic>;
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<Reaction>((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()
};
}

View File

@@ -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<String, Object> parameters = const {}})
List<ActionParameter> 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<String, dynamic>;
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<String, dynamic>)));
}
/// 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();
}
}

View File

@@ -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<AerisAPI>().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<Service> 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");
}
}

View File

@@ -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<String, Object> parameters = const {},
List<ActionParameter> 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<String, dynamic>;
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, dynamic>))
);
}
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();
}
}

View File

@@ -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});
}

View File

@@ -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<Service, List<ActionTemplate>> _triggerTemplates = {};
final Map<Service, List<ActionTemplate>> _reactionTemplates = {};
Map<Service, List<ActionTemplate>> get triggerTemplates => _triggerTemplates;
Map<Service, List<ActionTemplate>> 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<AerisAPI>().getAbout().then((about) {
if (about.isEmpty || about == null) return;
final services = (about['server'] as Map<String, dynamic>)['services'] as List<dynamic>;
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();
}
}

View File

@@ -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<void> fetchPipelines() {
return GetIt.I<AerisAPI>().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<AerisAPI>().createPipeline(newPipeline);
_pipelineCollection.pipelines.add(newPipeline);
GetIt.I<AerisAPI>().createPipeline(newPipeline);
sortPipelines();
notifyListeners();
}

View File

@@ -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<Service> _connectedServices = [];
List<Service> get connectedServices => _connectedServices;
/// Get the services the user is not connected to
List<Service> 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<AerisAPI>()
.connectService(service, code)
.then((value) => notifyListeners());
}
/// Refresh services from API
refreshServices() async {
_connectedServices = await GetIt.I<AerisAPI>().getConnectedService();
notifyListeners();
}
/// Removes a service from the Provider, and calls API
removeService(Service service) async {
_connectedServices.remove(service);
notifyListeners();
await GetIt.I<AerisAPI>().disconnectService(service);
}
}

View File

@@ -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<UserService> 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<UserService> 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();
}
}

View File

@@ -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<ServiceProvider>(context, listen: false).addService(service, code).then((_) {
Provider.of<ServiceProvider>(context, listen: false).notifyListeners();
Navigator.pop(context);
});
return Container(
alignment: Alignment.center,
child: LoadingIndicator(
indicatorType: Indicator.ballClipRotateMultiple,
colors: [Theme.of(context).colorScheme.secondary],
));
}
}

View File

@@ -79,15 +79,19 @@ class _CreatePipelinePageState extends State<CreatePipelinePage> {
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<CreatePipelinePage> {
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<CreatePipelinePage> {
showAerisCardPage(
context,
(_) => SetupActionPage(
action: newreact))
action: newreact,
parentReactions: reactions,
parentTrigger: trigger == Trigger.template() ? null : trigger,
))
.then((_) => setState(() {
if (newreact != Reaction.template()) {
reactions.add(newreact);

View File

@@ -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,23 +30,25 @@ class _HomePageState extends State<HomePage> {
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<String>(
context: context,
builder: (BuildContext context) => WarningDialog(
message: AppLocalizations.of(context).logoutWarningMessage,
onAccept: () => Navigator.of(context).popAndPushNamed('/'), //TODO logout
warnedAction: AppLocalizations.of(context).logout
)
),
onAccept: () {
GetIt.I<AerisAPI>().stopConnection();
Navigator.of(context).popAndPushNamed('/');
},
warnedAction: AppLocalizations.of(context).logout)),
);
return Consumer<PipelineProvider>(
builder: (context, provider, _) => AerisPage(
floatingActionButton: FloatingActionButton(
onPressed: () => showAerisCardPage(context, (_) => const CreatePipelinePage()),
onPressed: () => showAerisCardPage(
context, (_) => const CreatePipelinePage()),
backgroundColor: Theme.of(context).colorScheme.secondary,
elevation: 10,
child: const Icon(Icons.add),
@@ -57,27 +61,36 @@ class _HomePageState extends State<HomePage> {
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)),
? 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
)])
highlightColor:
Theme.of(context).colorScheme.secondary)
])
: LiquidPullToRefresh(
borderWidth: 2,
animSpeedFactor: 3,
color: Colors.transparent,
showChildOpacityTransition: false,
onRefresh: () => provider.fetchPipelines()
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),
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),
PipelineCard(
pipeline: provider.getPipelineAt(index),
),
)),
));

View File

@@ -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<String?> _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) {
Future<String?> _authUser(LoginData data) async {
bool connected =
await GetIt.I<AerisAPI>().createConnection(data.name, data.password);
if (!connected) {
return AppLocalizations.of(Aeris.materialKey.currentContext!)
.usernameOrPasswordIncorrect;
}
return null;
});
}
/// Opens signup page of [FlutterLogin] widget
Future<String?> _signupUser(SignupData data) {
debugPrint('Signup Name: ${data.name}, Password: ${data.password}');
return Future.delayed(loginDuration).then((_) {
return null;
});
}
/// Opens user password recovery page
Future<String?> _recoverPassword(String name) {
debugPrint('Name: $name');
return Future.delayed(loginDuration).then((_) {
if (!users.containsKey(name)) {
return AppLocalizations.of(Aeris.materialKey.currentContext!)
.userDoesNotExist;
Future<String?> _signupUser(SignupData data) async {
bool connected =
await GetIt.I<AerisAPI>().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);
}));
}
}

View File

@@ -95,7 +95,12 @@ class _PipelineDetailPageState extends State<PipelineDetailPage> {
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<PipelineDetailPage> {
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<PipelineDetailPage> {
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: () {

View File

@@ -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<Widget> getServiceGroup(String groupName, Icon trailingIcon,
List<Widget> getServiceGroup(List<Service> services, String groupName, Icon trailingIcon,
void Function(Service) onTap, BuildContext context) {
UserServiceProvider uServicesProvider =
Provider.of<UserServiceProvider>(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,34 +38,22 @@ class ServicePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
List<Service> services = const [
Service.discord(),
Service.gmail(),
Service.github(),
Service.youtube(),
Service.twitter(),
Service.spotify()
];
UserServiceProvider uServiceProvider =
Provider.of<UserServiceProvider>(context, listen: false);
uServiceProvider.clearProvider();
for (var service in services) {
uServiceProvider.createUserService(service);
}
return Consumer<PipelineProvider>(
builder: (context, provider, _) => AerisCardPage(
return Consumer<ServiceProvider>(
builder: (context, serviceProvider, _) => Consumer<PipelineProvider>(
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)),
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(
@@ -78,34 +61,22 @@ class ServicePage extends StatelessWidget {
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<AerisAPI>().disconnectService(service)
},
warnedAction:
AppLocalizations.of(context).disconnect)),
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) =>
print("Connected") /* TODO open page to connect service*/,
const Icon(Icons.connect_without_contact, color: Colors.green),
(Service service) {
launch(Uri.parse(service.authUrl).toString(), forceSafariVC: false);
},
context),
],
),
),
);
));
}
}

View File

@@ -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<Reaction> parentReactions;
@override
State<SetupActionPage> createState() => _SetupActionPageState();
@@ -30,9 +37,7 @@ class _SetupActionPageState extends State<SetupActionPage> {
void initState() {
super.initState();
serviceState = widget.action.service;
GetIt.I<AerisAPI>().getActionsFor(serviceState!, widget.action).then((actions) => setState(() {
availableActions = actions;
}));
availableActions = GetIt.I<AerisAPI>().getActionsFor(serviceState!, widget.action);
}
@override
@@ -43,12 +48,9 @@ class _SetupActionPageState extends State<SetupActionPage> {
elevation: 8,
underline: Container(),
onChanged: (service) {
GetIt.I<AerisAPI>().getActionsFor(service!, widget.action).then((actions) => setState(() {
availableActions = actions;
}));
setState(() {
serviceState = service;
availableActions = [];
availableActions = GetIt.I<AerisAPI>().getActionsFor(service!, widget.action);
});
},
items: Service.all().map<DropdownMenuItem<Service>>((Service service) {
@@ -66,6 +68,9 @@ class _SetupActionPageState extends State<SetupActionPage> {
}).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<SetupActionPage> {
),
],
),
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)
]
],

View File

@@ -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<StartupPage> {
bool connected = false;
@override
void initState() {
super.initState();
GetIt.I<AerisAPI>().getAbout().then((value) {
setState(() {
connected = value.isNotEmpty;
});
});
}
@override
Widget build(BuildContext context) {
bool isConnected = GetIt.I<AerisAPI>().connected;
bool isConnected = GetIt.I<AerisAPI>().isConnected;
return AerisPage(
displayAppbar: false,
floatingActionButton: SetupAPIRouteButton(
connected: connected,
onSetup: () => GetIt.I<AerisAPI>().getAbout().then((value) {
setState(() {
connected = value.isNotEmpty;
});
})
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -48,13 +68,13 @@ class _StartupPageState extends State<StartupPage> {
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)

View File

@@ -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<Reaction> 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
),
enabled: deletable),
]);
}
}

View File

@@ -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<int, ActionParameter, ActionTemplate> {
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<String> parametersNames;
/// Initial values of the fields
final Map<String, Object> initValues;
/// List of parameters, 'values' are used as default values
final List<ActionParameter> 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<Reaction> reactionsCandidates;
/// On validate callback
final void Function(Map<String, String>) 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<ActionForm> {
final _formKey = GlobalKey<FormBuilderState>();
final _formKey = GlobalKey<FormState>();
List<Suggestion> getSuggestions(String pattern, ActionCatalogueProvider catalogue) {
List<Suggestion> 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<String, String> values = {};
@override
Widget build(BuildContext context) {
return FormBuilder(
return Consumer<ActionCatalogueProvider>(
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,
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<Suggestion>(
key: Key(param.description),
textFieldConfiguration: TextFieldConfiguration(
autofocus: true,
controller: textEditingController,
enableSuggestions: widget.candidate is Reaction,
decoration: InputDecoration(
labelText: name,
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),
]),
keyboardType: (widget.initValues.containsKey(name)) && widget.initValues[name] is int ? TextInputType.number : null,
)),
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<ActionForm> {
]
]
)
);
));
}
}

View File

@@ -50,7 +50,6 @@ class HomePageSortMenu extends StatelessWidget {
value: !split),
],
onSelected: (sortingMethod) {
/// TODO: not clean
if (sortingMethod is bool) {
collectionProvider.splitDisabled = sortingMethod;
} else {

View File

@@ -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<Reaction> reactionList;
final List<Reaction> reactionList;
// Callback when a list has been reordered
final void Function() onReorder;

View File

@@ -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<SetupAPIRouteButton> createState() => _SetupAPIRouteButtonState();
}
class _SetupAPIRouteButtonState extends State<SetupAPIRouteButton> {
@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<SetupAPIRouteModal> createState() => _SetupAPIRouteModalState();
}
class _SetupAPIRouteModalState extends State<SetupAPIRouteModal> {
bool? connected;
final _formKey = GlobalKey<FormBuilderState>();
@override
void initState() {
super.initState();
GetIt.I<AerisAPI>().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<AerisAPI>().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<AerisAPI>().baseRoute;
GetIt.I<AerisAPI>().baseRoute = route;
setState(() {
connected = null;
});
GetIt.I<AerisAPI>().getAbout().then((value) {
setState(() {
connected = value.isNotEmpty;
});
}, onError: (_) => GetIt.I<AerisAPI>().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<SharedPreferences>()
.setString('api', GetIt.I<AerisAPI>().baseRoute);
Provider.of<ActionCatalogueProvider>(context, listen: false).reloadCatalogue();
Navigator.of(context).pop();
}
: null,
)
]);
}
}

View File

@@ -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:

View File

@@ -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"

View File

@@ -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 {