Flutter

Flutter with ChannelTalk

kahnco 2023. 1. 6. 13:36

Flutter로 프로젝트를 진행하다가, 문의하기 채널로써 채널톡을 활용하게 되었습니다.

단순히 채널톡 버튼을 띄우는 것은 어렵지 않았지만, 파일 업로드 부분에서 어려움을 겪었기 때문에 이 글을 보시는 분들은 저랑 같은 어려움을 겪지 않길 바라는 마음에서 이 글을 작성합니다.

 

채널톡은 Android, iOS, Javascript, React Native를 지원하기 때문에 공식적으로는 Flutter를 지원하지 않습니다. 따라서, Android, iOS를 각각 설정해준 다음 methodChannel을 통해서 Flutter와 Native 단의 통신을 설정해줘야합니다. 하지만 이 부분은 난이도가 높고, 시간이 많이 걸리는 작업이기 때문에 저는 React를 활용해서 웹페이지에 채널톡 버튼을 구현하고 이를 Flutter의 InAppWebView를 활용해서 띄워줄 예정입니다.

 

0. 사전 준비

 

먼저, 채널톡 계정이 필요합니다. 채널톡으로 접속하셔서 본인 정보를 기입하시고 채널을 생성해주시면 됩니다.

그리고 관리자 페이지에서 설정 -> 일반 설정 -> 버튼 설치 및 설정 -> 채널톡 버튼 설치 로 접근합니다.

이 화면대로 접근하시면 됩니다

 

그리고 기본 자바스크립트 탭으로 접근하셔서, 회원정보 연동 예제보기 체크박스를 해제한 버전의 코드를 그대로 복사해줍니다.

체크박스 설정하면 익명유저 설정이 안돼요

 

여기까지 따라오셨으면 채널톡에서 해야할 작업은 모두 끝났습니다.


1. 웹 페이지 구현

 

이제 React로 채널톡 버튼을 띄워보겠습니다. PC에 NPMCreate-React-App이 설치되어 있다는 가정하에 진행하겠습니다. Create-React-App 명령어로 새로운 React 프로젝트를 생성합니다.

 

create-react-app channel-talk # --template=typescript (타입스크립트로 생성하고 싶은 경우 추가)

 

새로운 프로젝트가 생성되었습니다

 

이제 public 폴더로 들어가셔서 Index.html 파일을 열어줍니다. 열어보면 대략 이런 식으로 코드가 구성되어 있습니다.

 

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta name="description" content="Web site created using create-react-app" />
  <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
  <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
  <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
  <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.

      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
  <title>React App</title>
</head>

<body>
	여기에 아까 채널톡에서 복사한 내용을 그대로 넣어줍니다
</body>

</html>

 

아무것도 없고 React 초기값만 들어있는 HTML 파일입니다. 위 파일에서 body 부분에 아까 채널톡에서 복사해온 코드를 그대로 복붙해줍니다.

그리고 나서 터미널에서 npm run start 명령어를 통해서 빌드를 해보면 다음과 같이 화면의 우측 하단에 채널톡 버튼이 생성되었음을 알 수 있습니다.

 

저 웃고 있는 버튼이 그겁니다

 

이제 이 React 프로젝트를 Live Server & Ngrok, AWS Amplfiy, Firebase hosting 등등을 활용해서 배포시켜줍니다.

 


2. Flutter 세팅

 

먼저, 해당 웹 페이지를 띄워줄 WebView Widget이 필요합니다. 저는 flutter_inappwebview 를 사용했습니다. 해당 라이브러리를 pubspec에 추가시킨 후, 다음과 같이 CustomInAppWebView Widget을 생성했습니다.

 

import 'dart:io';

import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:url_launcher/url_launcher.dart';

class CustomInAppWebView extends StatefulWidget {
  String url;

  CustomInAppWebView({
    Key? key,
    required this.url,
  }) : super(key: key);

  @override
  _CustomInAppWebViewState createState() => _CustomInAppWebViewState();
}

class _CustomInAppWebViewState extends State<CustomInAppWebView> {
  final GlobalKey webViewKey = GlobalKey();

  InAppWebViewController? webViewController;
  InAppWebViewGroupOptions options = InAppWebViewGroupOptions(
      crossPlatform: InAppWebViewOptions(
        useShouldOverrideUrlLoading: true,
        mediaPlaybackRequiresUserGesture: false,
      ),
      android: AndroidInAppWebViewOptions(
        useHybridComposition: true,
      ),
      ios: IOSInAppWebViewOptions(
        allowsInlineMediaPlayback: true,
      ));

  late PullToRefreshController pullToRefreshController;
  double progress = 0;
  final urlController = TextEditingController();

  @override
  void initState() {
    super.initState();

    pullToRefreshController = PullToRefreshController(
      options: PullToRefreshOptions(
        color: Colors.blue,
      ),
      onRefresh: () async {
        if (Platform.isAndroid) {
          webViewController?.reload();
        } else if (Platform.isIOS) {
          webViewController?.loadUrl(urlRequest: URLRequest(url: await webViewController?.getUrl()));
        }
      },
    );
  }

  @override
  void dispose() {
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: InAppWebView(
          key: webViewKey,
          initialUrlRequest: URLRequest(url: Uri.parse(widget.url)),
          initialOptions: options,
          pullToRefreshController: pullToRefreshController,
          onWebViewCreated: (controller) {
            webViewController = controller;
          },
          onLoadStart: (controller, url) {
            setState(() {
              widget.url = url.toString();
              urlController.text = widget.url;
            });
          },
          androidOnPermissionRequest: (controller, origin, resources) async {
            return PermissionRequestResponse(resources: resources, action: PermissionRequestResponseAction.GRANT);
          },
          shouldOverrideUrlLoading: (controller, navigationAction) async {
            var uri = navigationAction.request.url!;

            if (!["http", "https", "file", "chrome", "data", "javascript", "about"].contains(uri.scheme)) {
              if (await canLaunchUrl(Uri.parse(widget.url))) {
                // Launch the App
                await launchUrl(Uri.parse(widget.url));
                // and cancel the request
                return NavigationActionPolicy.CANCEL;
              }
            }

            return NavigationActionPolicy.ALLOW;
          },
          onLoadStop: (controller, url) async {
            pullToRefreshController.endRefreshing();
            setState(() {
              widget.url = url.toString();
              urlController.text = widget.url;
            });
          },
          onLoadError: (controller, url, code, message) {
            pullToRefreshController.endRefreshing();
          },
          onProgressChanged: (controller, progress) {
            if (progress == 100) {
              pullToRefreshController.endRefreshing();
            }
            setState(() {
              this.progress = progress / 100;
              urlController.text = widget.url;
            });
          },
          onUpdateVisitedHistory: (controller, url, androidIsReload) {
            setState(() {
              widget.url = url.toString();
              urlController.text = widget.url;
            });
          },
          onConsoleMessage: (controller, consoleMessage) {
            debugPrint('CustomInAppWebView error ${consoleMessage.message}');
          },
        ),
      ),
    );
  }
}

 

파라미터로 주어진 URL로 이동하기 위해서, 다음과 같은 함수를 사용했습니다.

 

Future<void> showInAppWebView(String url) async {
  if (kIsWeb) {
    await launchUrl(Uri.parse(url));
  }
  if (!kIsWeb) {
    Get.to(CustomInAppWebView(url: url));
  }
}

 

Flutter 레이어에서 설정해야하는 부분은 끝났습니다. 이제 Native 단으로 들어가보겠습니다.

 


3. Android 세팅

 

먼저 AndroidManifest.xml 파일에서 필요한 권한을 세팅해보겠습니다.

AndroidManifest 파일을 다음과 같이 수정해줍니다.

 

# path : android/app/src/main/AndroidManifext.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example">

	<!-- 여기 부분을 추가해야합니다
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.CAMERA" />
        <uses-feature
            android:name="android.hardware.camera"
            android:required="true" />
    -->

    <application
        android:name="${applicationName}"
        ~~~~~~
        >
        <activity
            android:name=".MainActivity"
            ~~~~
        </activity>
        
        <!-- 여기 부분도 추가해야합니다
        
        <provider
            android:name="com.pichillilorenzo.flutter_inappwebview.InAppWebViewFileProvider"
            android:authorities="${applicationId}.flutter_inappwebview.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_paths" />
        </provider>
        
        -->
        
        <!-- 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>

 

이후에, build 수준의 gradle에서 defaultConfig 부분의 minSdkVersion을 24로 수정해줍니다.

 

# path : android/app/src/build.gradle

defaultConfig {
    // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
    applicationId "com.example"
    // You can update the following values to match your application needs.
    // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
        
    minSdkVersion 24	// 이 부분이 수정되어야 합니다
    targetSdkVersion 33
    versionCode flutterVersionCode.toInteger()
    versionName flutterVersionName
}

 

Android Native에서 설정해야할 부분은 끝났습니다.

 


4. iOS 세팅

 

ios 폴더에서 Podfile 파일에 권한 관련 내용을 추가해보겠습니다. 아래 코드에서 하단의 post install 부분만 참고하시면 됩니다

 

# path: ios/Podfile

# Uncomment this line to define a global platform for your project
platform :ios, '12.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!
  use_modular_headers!

  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
    
    # 여기서부터 추가해주셔야합니다
    target.build_configurations.each do |config|
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',
        # dart: PermissionGroup.camera
        'PERMISSION_CAMERA=1',

        # dart: PermissionGroup.photos
        'PERMISSION_PHOTOS=1',
    ]

    end
    # End of the permission_handler configuration
  end
end

 

그리고, Info.plist에서 다음 값들을 추가해줍니다.

 

# path: ios/Runner/info.plist

<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Attale Pro requires access to your phone’s camera.</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<string>YES</string>

 

이렇게까지 설정하면 InAppWebView에서 채널톡을 사용할 수 있으며, 파일 업로드 또한 가능합니다.

 

여타 질의사항은 댓글로 남겨주시면 답변드리겠습니다.

감사합니다.

반응형

'Flutter' 카테고리의 다른 글

In Flutter, which Empty Widget is most fastest?  (0) 2023.01.29
Flutter Build Flavor with Firebase (1)  (0) 2023.01.04
CheckBox Inner Color Not Changed  (0) 2022.12.28
Flutter Clean Architecture  (0) 2022.12.28