Mike  Kozey

Mike Kozey

1661946300

Embeddedsdk: Passwordless Identities for Workforces and Customers

Beyond Identity Flutter SDK

Embedded

The Embedded SDK is a holistic SDK solution offering the entire experience embedded in your product. Users will not need to download the Beyond Identity Authenticator.

Installation

Add the Beyond Identity Embedded SDK to your dependencies

dependencies:
  embeddedsdk: x.y.z

and run an implicit flutter pub get

Usage

Check out the documentation for more information.

Example app

To run the Android example app

  1. Run flutter pub get from the root of the repo
  2. Run flutter run from the example directory or use Android Studio. Make sure an Android device is running.

To run the iOS example app

  1. Run flutter pub get from the root of the repo
  2. Run pod install --repo-update from example/ios directory
  3. Run flutter run from the example directory or use XCode.

Use this package as a library

Depend on it

Run this command:

With Flutter:

 $ flutter pub add embeddedsdk

This will add a line like this to your package's pubspec.yaml (and run an implicit flutter pub get):

dependencies:
  embeddedsdk: ^0.2.0

Alternatively, your editor might support flutter pub get. Check the docs for your editor to learn more.

Import it

Now in your Dart code, you can use:

import 'package:embeddedsdk/embeddedsdk.dart';

example/lib/main.dart

import 'dart:async';
import 'dart:convert';

import 'package:embeddedsdk/embeddedsdk.dart';
import 'package:embeddedsdk_example/config.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:uni_links/uni_links.dart';
import 'package:http/http.dart' as http;
import 'package:http_interceptor/http_interceptor.dart';
import 'package:uuid/uuid.dart';

bool _initialUriIsHandled = false;

class LoggingInterceptor implements InterceptorContract {
  @override
  Future<RequestData> interceptRequest({required RequestData data}) async {
    print(data.toString());
    return data;
  }

  @override
  Future<ResponseData> interceptResponse({required ResponseData data}) async {
    print(data.toString());
    return data;
  }

}

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final bool _enableLogging = true;
  final _createUserController = TextEditingController();
  String _createUserText = '';

  final _recoverUserController = TextEditingController();
  String _recoverUserText = '';

  Uri? _initialUri;
  Uri? _latestUri;
  Object? _err;
  StreamSubscription? _sub;

  Credential? _credentialRegistered;
  String _credentialRegisteredText = '';

  List<Credential>? _credentials;
  String _credentialsText = '';

  String _exportTokenText = '';

  String _importText = '';
  final _importTokenController = TextEditingController();

  String _deleteCredentialResult = '';

  String _authorizationCode = '';
  String _authorizationCodeText = '';
  String _authorizationExchangeTokenText = '';

  String _authTokenText = '';
  PKCE? _pkce;

  exportUpdateCallback(Map<String, String?> data) async {
    debugPrint("Export callback invoked $data");
    String? status = data["status"];
    String exportTokenText = "";
    if(status != null) {
      switch(status) {
        case ExtendCredentialsStatus.update: {
          if(data["token"] != null) {
            exportTokenText = "Extend token = ${data["token"]}";
          } else {
            exportTokenText = "error getting the token | data = $data";
          }
          break;
        }
        case ExtendCredentialsStatus.finish: {
          exportTokenText = "Credential extended successfully";
          break;
        }
        case ExtendCredentialsStatus.error: {
          exportTokenText = data["errorMessage"] ?? "error getting the errorMessage | data = $data";
          break;
        }
      }
    } else {
      exportTokenText = "extend credential status was null";
    }

    if (!mounted) return;

    setState(() {
      _exportTokenText = exportTokenText;
    });
  }

  @override
  void initState() {
    super.initState();
    Embeddedsdk.initialize(BuildConfig.PUBLIC_CLIENT_ID, "Gimmie your biometrics", BuildConfig.REDIRECT_URI, _enableLogging);
    _handleInitialUri();
    _handleIncomingLinks();
  }

  @override
  void dispose() {
    _sub?.cancel();
    Embeddedsdk.cancelExtendCredentials();
    _importTokenController.dispose();
    super.dispose();
  }

  void _createDemoUser() async {
    String createUserText = '';
    http.Client client = InterceptedClient.build(interceptors: [
      LoggingInterceptor()
    ]);

    try {
      var uuid = const Uuid().v4().toString();
      var response = await client.post(
          Uri.parse(BuildConfig.CREATE_USER_URL),
          headers: <String, String>{
            'Content-Type': 'application/json; charset=UTF-8',
          },
          body: jsonEncode({
            'binding_token_delivery_method': 'email',
            'external_id': _createUserController.text,
            'email': _createUserController.text,
            'user_name': uuid,
            'display_name': uuid,
          }));

      createUserText = response.body;

    } on Exception catch(e) {
      createUserText = "Error creating user $e";
    } finally {
      client.close();
    }

    setState(() {
      _createUserText = createUserText;
    });
  }

  void _recoverDemoUser() async {
    String recoverUserText = '';
    http.Client client = InterceptedClient.build(interceptors: [
      LoggingInterceptor()
    ]);

    try {
      var response = await client.post(
          Uri.parse(BuildConfig.RECOVER_USER_URL),
          headers: <String, String>{
            'Content-Type': 'application/json; charset=UTF-8',
          },
          body: jsonEncode({
            'binding_token_delivery_method': 'email',
            'external_id': _recoverUserController.text,
          }));

      recoverUserText = response.body;

    } on Exception catch(e) {
      recoverUserText = "Error recovering user $e";
    } finally {
      client.close();
    }

    setState(() {
      _recoverUserText = recoverUserText;
    });
  }

  void _exchangeAuthzCodeForTokens() async {
    String responseText = '';
    if (_authorizationCode.isNotEmpty) {
      http.Client client = InterceptedClient.build(interceptors: [
        LoggingInterceptor()
      ]);

      Map<String, String> params = {
        'code': _authorizationCode,
        'redirect_uri': BuildConfig.REDIRECT_URI,
        'grant_type': 'authorization_code',
      };

      if(_pkce != null) {
        params['code_verifier'] = _pkce!.codeVerifier;
      }

      try {
        var response = await client.post(
            Uri.parse(BuildConfig.TOKEN_ENDPOINT),
            headers: <String, String>{
              'Content-Type': 'application/x-www-form-urlencoded',
              'Authorization': 'Basic ' + base64Encode(utf8.encode('${BuildConfig.CONFIDENTIAL_CLIENT_ID}:${BuildConfig.CONFIDENTIAL_CLIENT_SECRET}'))
            },
            encoding: Encoding.getByName('utf-8'),
            body: params
        );

        responseText = response.body;

      } on Exception catch(e) {
        responseText = "Error exchanging authorization code for tokens $e";
      } finally {
        client.close();
      }
    } else {
      responseText = "Get Authorization Code first by clicking the Authorize button";
    }

    setState(() {
      _authorizationExchangeTokenText = responseText;
      _pkce = null;
    });
  }

  /// Handle incoming links - the ones that the app will recieve from the OS
  /// while already started.
  void _handleIncomingLinks() {
    // It will handle app links while the app is already started - be it in
    // the foreground or in the background.
    _sub = uriLinkStream.listen((Uri? uri) {
      if (!mounted) return;
      debugPrint('got uri: $uri');
      _registerCredentialsWithUrl(uri);
      setState(() {
        _latestUri = uri;
        _err = null;
      });
    }, onError: (Object err) {
      if (!mounted) return;
      debugPrint('got err: $err');
      setState(() {
        _latestUri = null;
        if (err is FormatException) {
          _err = err;
        } else {
          _err = null;
        }
      });
    });
  }

  /// Handle the initial Uri - the one the app was started with
  ///
  /// **ATTENTION**: `getInitialLink`/`getInitialUri` should be handled
  /// ONLY ONCE in your app's lifetime, since it is not meant to change
  /// throughout your app's life.
  ///
  /// We handle all exceptions, since it is called from initState.
  Future<void> _handleInitialUri() async {
    // In this example app this is an almost useless guard, but it is here to
    // show we are not going to call getInitialUri multiple times, even if this
    // was a weidget that will be disposed of (ex. a navigation route change).
    if (!_initialUriIsHandled) {
      _initialUriIsHandled = true;

      try {
        final uri = await getInitialUri();
        if (uri == null) {
          debugPrint('no initial uri');
        } else {
          debugPrint('got initial uri: $uri');
        }
        _registerCredentialsWithUrl(uri);
        if (!mounted) return;
        setState(() {
          _initialUri = uri;
        });
      } on PlatformException {
        // Platform messages may fail but we ignore the exception
        debugPrint('falied to get initial uri');
      } on FormatException catch (err) {
        if (!mounted) return;
        debugPrint('malformed initial uri');
        setState(() => _err = err);
      }
    }
  }

  Future<void> _registerCredentialsWithUrl(Uri? registerUri) async {
    Credential? credential;
    String regCredText = '';
    // Platform messages may fail, so we use a try/catch PlatformException.
    // We also handle the message potentially returning null.
    if (registerUri != null) {
      try {
        String uri = registerUri.toString().replaceAll(":?", "://host/register?");
        debugPrint("Register with = $uri");
        credential = await Embeddedsdk.registerCredentialsWithUrl(uri);
        regCredText = "Credential succeffuly registered";
      } on PlatformException catch (e) {
        debugPrint("platform exception = $e");
        regCredText = "Error registering credentials = $e";
      } on Exception catch (e) {
        debugPrint("exception = $e");
        regCredText = "Error registering credentials = $e";
      }
    }
    if (!mounted) return;

    setState(() {
      _credentialRegistered = credential;
      _credentialRegisteredText = regCredText;
    });
  }

  // Platform messages are asynchronous, so we initialize in an async method.
  Future<void> _getCreds() async {
    List<Credential>? credentials;
    String credText = '';
    // Platform messages may fail, so we use a try/catch PlatformException.
    // We also handle the message potentially returning null.
    try {
      credentials = await Embeddedsdk.getCredentials();
      credText = "Credentials = $credentials";
    } on PlatformException catch (e) {
      debugPrint("platform exception = $e");
      credText = "Error getting credentials = $e";
    } on Exception catch (e) {
      debugPrint("exception = $e");
      credText = "Error getting credentials = $e";
    }

    // If the widget was removed from the tree while the asynchronous platform
    // message was in flight, we want to discard the reply rather than calling
    // setState to update our non-existent appearance.
    if (!mounted) return;

    setState(() {
      _credentials = credentials;
      _credentialsText = credText;
    });
  }

  Future<void> _createPkce() async {
    PKCE? pkce;

    try {
      pkce = await Embeddedsdk.createPkce();
    } on PlatformException {
      pkce = PKCE(codeVerifier: "error", codeChallenge: "error", codeChallengeMethod: "error");
    }

    if (!mounted) return;

    setState(() {
      _pkce = pkce;
    });
  }

  Future<void> _authorize() async {
    String authz;
    String authzText;

    Embeddedsdk.initialize(BuildConfig.CONFIDENTIAL_CLIENT_ID, "Gimmie your biometrics", BuildConfig.REDIRECT_URI, _enableLogging);

    try {
      authz = await Embeddedsdk.authorize(
          "openid",
          _pkce?.codeChallenge,
      );
      authzText = "Authorization code = $authz";
    } on PlatformException catch(e) {
      authz = '';
      authzText = "Error getting authz code | error = $e";
    }

    if (!mounted) return;

    setState(() {
      _authorizationCode = authz;
      _authorizationCodeText = authzText;
    });
  }

  Future<void> _extendCredentials() async {
    String exportToken;
    String exportTokenText;

    try {
      exportTokenText = "Export started";
      // test-acme-corp for devel, sdk-demo for prod
      Embeddedsdk.extendCredentials(List.generate(1, (index) => BuildConfig.DEMO_TENANT_HANDLE), exportUpdateCallback);
    } on PlatformException {
      exportTokenText = "Error exporting credential";
    }

    if (!mounted) return;

    setState(() {
      _exportTokenText = exportTokenText;
    });
  }

  Future<void> _cancelExtendCredentials() async {
    String cancelText = "";

    try {
      await Embeddedsdk.cancelExtendCredentials();
      cancelText = "Extend credentials cancelled";
    } on PlatformException {
      cancelText = "Error cancelling extend credentials";
    }

    if (!mounted) return;

    setState(() {
      _exportTokenText = cancelText;
    });
  }

  Future<void> _authenticate() async {
    TokenResponse token;
    String tokenText = "";

    Embeddedsdk.initialize(BuildConfig.PUBLIC_CLIENT_ID, "Gimmie your biometrics", BuildConfig.REDIRECT_URI, _enableLogging);
    
    try {
      token = await Embeddedsdk.authenticate();
      tokenText = token.toString();
    } on PlatformException {
      tokenText = 'Error getting auth token';
    }

    if (!mounted) return;

    setState(() {
      _authTokenText = tokenText;
    });
  }

  Future<void> _deleteCredential() async {
    String handle;

    try {
      handle = await Embeddedsdk.deleteCredential(BuildConfig.DEMO_TENANT_HANDLE);
    } on PlatformException catch(e) {
      handle = "could not delete credential $e";
    }

    if (!mounted) return;

    setState(() {
      _deleteCredentialResult = handle;
    });
  }

  Future<void> _registerCredentials() async {
    String importText = '';

    try {
      // test-acme-corp for devel, sdk-demo for prod
      await Embeddedsdk.registerCredentialsWithToken(_importTokenController.text);
      importText = "Credentials succesfully registered";
    } on PlatformException catch(e) {
      importText = "Error registering credentials $e";
    }

    if (!mounted) return;

    setState(() {
      _importText = importText;
      _importTokenController.text = "";
    });
  }

  ///////////////////////////////////////////////////
  ///////////////// UI //////////////////////////////
  ///////////////////////////////////////////////////

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: Scaffold(
      appBar: AppBar(
        title: const Text('Embedded SDK example app'),
      ),
      body: Container(
        color: Colors.grey[300],
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(4),
          child: Column(
            children: [
              _card([
                _title("Demo Utils"),
                _description("\nCreate user for testing. Get an email with registration link."),
                _buttonInputTextGroup("Create Demo User", "User Email", _createUserController, _createDemoUser, _createUserText),
                _description("\nRecover existing user for testing. Get an email with recovery link."),
                _buttonInputTextGroup("Recover Demo User", "User Email", _recoverUserController, _recoverDemoUser, _recoverUserText),
              ]),
              _card([
                _title("\nEmbedded SDK Functionality"),
                _title("\nCredentials"),
                _subTitle("Credential is what identifies the user on this device."),
                Text(_credentialRegisteredText),
                _buttonTextGroup("Get Credentials", _getCreds, _credentialsText),
                _buttonTextGroup("Delete Credential", _deleteCredential, _deleteCredentialResult),
              ]),
              _card([
                _title("Extended/Register Credentials"),
                _subTitle("Before authneticating on another device, the credential needs to be transfered to that device."),
                _description("\nTransfer a credential from this to another device. Extend Credentials will generate an extended credential that can be used to register the credential on another device\nNOTE: Lock screen needs to be set on the device!"),
                _buttonTextGroup("Extend Credentials", _extendCredentials, _exportTokenText),
                _description("\nExtending Credentials blocks the Embedded SDK from performing other operations. The extended credential needs to finish or be explicitly cancelled."),
                _buttonTextGroup("Cancel Credentials Extend", _cancelExtendCredentials, ""),
                _description("To register credential from another device, enter the extended credential generated on that device."),
                _buttonInputTextGroup("Register Credentials", 'Extend credential from other device.', _importTokenController, _registerCredentials, _importText),
              ]),
              _card([
                _title("Access and ID token"),
                _subTitle("After successful authentication you will get an Access and ID token, used to get information on the user and authenticate on APIs. The flow of getting the tokens will depend on your OIDC client configuration."),
                _subTitle("There are 2 types of clients, Confidential (with client secret) and Public (without client secret).\n"),
                _title("OIDC Public client"),
                _subTitle("Public clients are unable to keep the secret secure (e.x. front end app with no backend)\n"),
                _description("Use authenticate function when your client is configured as public, it will go through the whole flow and get the Access and ID tokens"),
                _buttonTextGroup("Authenticate", _authenticate, _authTokenText),
                _title("\nOIDC Confidentail client"),
                _subTitle("Confidentail client are able to keep the secret secure (e.x. your backend)\n"),
                _description("(OPTIONAL) Use PKCE for increased security. If the flow is started with PKCE it needs to be completed with PKCE. Read more in the documentation."),
                _buttonTextGroup("Generate PKCE challenge", _createPkce, _pkce?.toString() ?? ""),
                _description("\nUse authorize function when your client is configured as confidentail. You will get an authorization code that needs to be exchanged for Access and ID token."),
                _buttonTextGroup("Authorize", _authorize, _authorizationCodeText),
                _subTitle("\nExchange Authorization Code for Acccess and ID token."),
                _description("NOTE: To exchange the authorization code for Access and ID token we need the client secret."),
                _description("For demo purposes, we're storing the client secret on the device. DO NOT DO THIS IN PROD!"),
                _buttonTextGroup("Exchange Authz Code for Tokens", _exchangeAuthzCodeForTokens, _authorizationExchangeTokenText),
              ])
            ],
          ),
        ),
      )
    ));
  }
  
  Widget _card(List<Widget> widgets) {
    return Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: widgets,
          ),
        )
    );
  }

  Widget _title(String titleText) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      children: [
        Text(
            titleText,
            style: const TextStyle(
              fontWeight: FontWeight.w900,
              fontSize: 24,
            )
        )
      ],
    );
  }

  Widget _subTitle(String subTitleText) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      children: [
        Flexible(
          child: Text(
              subTitleText,
              style: const TextStyle(
                fontWeight: FontWeight.w500,
                fontSize: 16,
              )
          ),
        )
      ],
    );
  }

  Widget _description(String description) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      children: [
        Flexible(
          child: Text(
              description,
              style: const TextStyle(
                fontSize: 12,
              )
          ),
        )
      ],
    );
  }

  Widget _buttonTextGroup(String buttonLabel, VoidCallback callback, String text) {
    return Column(
        mainAxisSize: MainAxisSize.max,
        children: [
          ElevatedButton(
            child: Text(buttonLabel),
            onPressed: callback,
            style: ElevatedButton.styleFrom(
              fixedSize: const Size.fromWidth(double.maxFinite),
            ),
          ),
          SelectableText(text),
          ],
    );
  }

  Widget _buttonInputTextGroup(
      String buttonLabel,
      String inputLabel,
      TextEditingController controller,
      VoidCallback callback,
      String text,
      ) {
    return Column(
      mainAxisSize: MainAxisSize.max,
      children: [
        ElevatedButton(
          child: Text(buttonLabel),
          onPressed: callback,
          style: ElevatedButton.styleFrom(
            fixedSize: const Size.fromWidth(double.maxFinite),
          ),
        ),
        TextFormField(
          decoration: InputDecoration(
            border: const UnderlineInputBorder(),
            labelText: inputLabel,
          ),
          controller: controller,
        ),
        SelectableText("\n$text"),
      ],
    );
  }
}

Download Details:

Author: Gobeyondidentity
Source Code: https://github.com/gobeyondidentity/bi-sdk-flutter 
License: Apache-2.0 license

#flutter #dart #android #ios 

What is GEEK

Buddha Community

Embeddedsdk: Passwordless Identities for Workforces and Customers
Fynzo Survey

Fynzo Survey

1622049211

Fynzo Customer Feedback Software For Cafes, Hotels, Saloons, Spa!

Customer Feedback Tool | Fynzo online customer feedback comes with Android, iOS app. Collect feedback from your customers with tablets or send them feedback links.

Visit page for more information: https://www.fynzo.com/feedback

#CustomerFeedbackSystem
#PowerfulCustomerFeedbackSystem
#freecustomerfeedbacktools
#automatedcustomerfeedbacksystem
#customerfeedbacktools
#customerratingsystem
#Customerfeedbackmanagement

#customer feedback system #powerful customer feedback system #free customer feedback tools #automated customer feedback system #customer feedback tools #customer rating system

Jammie  Yost

Jammie Yost

1666196400

A Procedural Macro for Defining Nom Combinators in Simple DSL For Rust

nom-rule  

A procedural macro for defining nom combinators in simple DSL. Requires nom v5.0+.

Dependencies

[dependencies]
nom = "7"
nom-rule = "0.2"

Syntax

The procedural macro rule! provided by this crate is designed for the ease of writing grammar spec as well as to improve maintainability, it follows these simple rules:

  1. TOKEN: match the token by token kind. You should provide a parser to eat the next token if the token kind matched. it will get expanded into match_token(TOKEN).
  2. ";": match the token by token text. You should provide a parser to eat the next token if the token text matched. it will get expanded into match_text(";") in this example.
  3. #fn_name: an external nom parser function. In the example above, ident is a predefined parser for identifiers.
  4. a ~ b ~ c: a sequence of parsers to take one by one. It'll get expanded into nom::sequence::tuple.
  5. (...)+: one or more repeated patterns. It'll get expanded into nom::multi::many1.
  6. (...)*: zero or more repeated patterns. It'll get expanded into nom::multi::many0.
  7. (...)?: Optional parser. It'll get expanded into nom::combinator::opt.
  8. a | b | c: Choices between a, b, and c. It'll get expanded into nom::branch::alt.
  9. &a: Peek. It'll get expanded into nom::combinator::peek(a). Note that it doesn't consume the input.
  10. !a: Negative predicate. It'll get expanded into nom::combinator::not. Note that it doesn't consume the input.
  11. ^a: Cut parser. It'll get expanded into nom::combinator::cut.
  12. ... : "description": Context description for error reporting. It'll get expanded into nom::error::context.

Example

Define match_text parser and match_token parser for your custom token type. You can use nom::combinator::fail as match_token if your parser use &str or &[u8] as input because you won't match on token kinds.

#[derive(Clone, Debug, PartialEq)]
struct Token<'a> {
    kind: TokenKind,
    text: &'a str,
    span: Span,
}

#[derive(Clone, Copy, Debug, PartialEq)]
enum TokenKind {
    Whitespace,

    // Keywords
    CREATE,
    TABLE,

    // Symbols
    LParen,
    RParen,
    Semicolon,
    Comma,

    Ident,
}

fn match_text<'a, Error: ParseError<Input<'a>>>(
    text: &'a str,
) -> impl FnMut(Input<'a>) -> IResult<Input<'a>, &'a Token<'a>, Error> {
    move |i| satisfy(|token: &Token<'a>| token.text == text)(i)
}

fn match_token<'a, Error: ParseError<Input<'a>>>(
    kind: TokenKind,
) -> impl FnMut(Input<'a>) -> IResult<Input<'a>, &'a Token<'a>, Error> {
    move |i| satisfy(|token: &Token<'a>| token.kind == kind)(i)
}

Then give the two parser to nom_rule::rule! by wrapping it into a custom macro:

macro_rules! rule {
    ($($tt:tt)*) => { 
        nom_rule::rule!($crate::match_text, $crate::match_token, $($tt)*)
    }
}

To define a parser for the SQL of creating table:

let mut rule = rule!(
    CREATE ~ TABLE ~ #ident ~ ^"(" ~ (#ident ~ #ident ~ ","?)* ~ ")" ~ ";" : "CREATE TABLE statement"
);

It will get expanded into:

let mut rule = 
    nom::error::context(
        "CREATE TABLE statement",
        nom::sequence::tuple((
            (crate::match_token)(CREATE),
            (crate::match_token)(TABLE),
            ident,
            (nom::combinator::cut(crate::match_text)("(")),
            nom::multi::many0(nom::sequence::tuple((
                ident,
                ident,
                nom::combinator::opt((crate::match_text)(",")),
            ))),
            (crate::match_text)(")"),
            (crate::match_text)(";"),
        ))
    );

Auto Sequence (nightly only)

nom-rule is able to automatically insert ~ in the rule when necessary so that you get the example above working the same as the following:

let mut rule = rule!(
    CREATE TABLE #ident "(" (#ident #ident ","?)* ")" ";" : "CREATE TABLE statement"
);

To enable this feature, you need to use a nightly channel rust complier, and add this to the Cargo.toml:

nom-rule = { version = "0.2", features = ["auto-sequence"] }

Download Details:

Author: andylokandy
Source Code: https://github.com/andylokandy/nom-rule

License: MIT license

#rust 

Erwin  Boyer

Erwin Boyer

1625278620

Is Tech Making or Breaking Your Customer Experience?

Technology can be a two-edged sword. It can deliver incredible results and create unique problems. The customer experience (CX) sector, in particular, has been heavily impacted by technology for quite some time.

Just because you’re using customer relationship management (CRM) tech, doesn’t mean it’s working, though. Here are a few questions to ask yourself to see if your tech is making or breaking your customer’s experience.

Is Your Customer Service Organized?

Are You Ignoring Phone Calls for Other Tech?

Is Your Customer Experience too Tech-Centric?

Is Your Customer Experience Obsessed with Speed?

#customer-experience #customer-service #technology #tech #customer-support #customer-engagement #bus #customer-support-chatbots

Custom Mobile App Development Services Company in USA

AppClues Infotech is the best custom mobile app development company in USA. We offer custom mobile app development services in USA to effectively solve your business purpose.

For more info:
Website: https://www.appcluesinfotech.com/
Email: info@appcluesinfotech.com
Call: +1-978-309-9910

#custom mobile app development #custom app development services #custom app development company in usa #custom mobile app developers in usa #custom app development agency in usa #hire custom mobile app developers

Custom Mobile App Development Services in USA

Want to build a custom mobile app for your business or startup? We at AppClues Infotech, provide the best custom mobile app development services in USA. We have highly skilled & creative team of custom mobile app designers and developers that will help to make a perfect mobile app with the latest features & functionalities.

However big or small your app development needs, we’ll build you a finest & effective mobile app that’s tailored specifically to your business needs.

Our Custom Mobile App Development Services:
• Android & iOS App Development
• Cross-Platform & Hybrid App Development
• Enterprise Mobility Solutions
• Mobile Commerce App Development
• Mobile Wallet App Development
• Wearable App Development
• UI/UX Design
• Mobile App Consulting

For more info:
Website: https://www.appcluesinfotech.com/
Email: info@appcluesinfotech.com
Call: +1-978-309-9910

#custom mobile app development company in usa #hire custom mobile app developers #top custom app development company in usa #how to build custom mobile app #custom mobile app development #custom mobile app development services in usa