Flutter로 만든 적응형 앱

1. 소개

Flutter는 하나의 코드베이스를 사용해 모바일, 웹, 데스크톱을 대상으로 아름다운 네이티브 컴파일 애플리케이션을 빌드하기 위한 Google의 UI 툴킷입니다. 이 Codelab에서는 실행 플랫폼(예: Android, iOS, 웹, Windows, macOS, Linux)에 맞게 조정되는 Flutter 앱을 빌드하는 방법을 알아봅니다.

학습할 내용

  • 모바일용으로 설계된 Flutter 앱을 Flutter에서 지원하는 6가지 플랫폼에서 모두 작동하도록 성장시키는 방법
  • 플랫폼을 감지하는 데 사용하는 다양한 Flutter API와 각 API를 사용하는 시점
  • 웹에서 앱을 실행하는 데 발생하는 제한사항과 예상에 맞게 조정하는 방법
  • Flutter의 모든 플랫폼을 지원하기 위해 서로 다른 패키지를 함께 사용하는 방법

빌드할 항목

이 Codelab에서는 먼저 Flutter의 YouTube 재생목록을 살펴보는 Flutter 앱을 Android와 iOS용으로 빌드합니다. 그런 다음 애플리케이션 창 크기에 따라 정보가 표시되는 방식을 수정하여 3개의 데스크톱 플랫폼(Windows, macOS, Linux)에 맞게 이 애플리케이션을 조정합니다. 그 후에는 웹 사용자가 예상하는 대로 앱에 표시되는 텍스트를 선택할 수 있도록 변경하여 애플리케이션을 웹용으로 조정합니다. 마지막으로 앱에 인증을 추가합니다. Flutter팀에서 만든 재생목록이 아닌 내 재생목록을 살펴볼 수 있으려면 Android, iOS 및 웹과 Windows, macOS, Linux 세 개의 데스크톱 플랫폼에 다른 인증 방식이 필요합니다.

다음은 Android와 iOS에서 실행되는 Flutter 앱의 스크린샷입니다.

다음은 와이드스크린 레이아웃의 macOS에서 실행되는 앱의 스크린샷입니다.

b424266e6fd4b3c3.png

이 Codelab은 모바일 Flutter 앱을 6개의 모든 Flutter 플랫폼에서 작동하는 적응형 앱으로 변환하는 데 초점을 두고 있습니다. 따라서 이와 관련 없는 개념과 코드 블록은 설명 없이 넘어가고 필요할 때 간단히 복사하여 붙여넣을 수 있도록 제공해 드립니다.

이 Codelab에서 배우고 싶은 내용은 무엇인가요?

주제를 처음 접하기 때문에 간단하게 내용을 파악하고 싶습니다. 이 주제에 관해 약간 알고 있지만 한 번 더 확인하고 싶습니다. 프로젝트에 사용할 예 코드를 찾고 있습니다. 구체적인 항목에 관한 설명을 찾고 있습니다.

2. Flutter 개발 환경 설정

이 실습을 완료하려면 Flutter SDK편집기라는 두 가지 소프트웨어가 필요합니다.

다음 기기 중 하나를 사용하여 이 Codelab을 실행할 수 있습니다.

  • 컴퓨터에 연결되어 있고 개발자 모드로 설정된 실제 Android 또는 iOS 기기
  • iOS 시뮬레이터(Xcode 도구 설치 필요)
  • Android Emulator(Android 스튜디오 설정 필요)
  • 브라우저(디버깅 시 Chrome 필요)
  • Windows, Linux 또는 macOS 데스크톱 애플리케이션. 앱을 배포할 플랫폼에서 개발해야 합니다. 따라서 Windows 데스크톱 앱을 개발하려면 적합한 빌드 체인에 액세스할 수 있도록 Windows에서 개발해야 합니다. 자세한 운영체제별 요구사항은 docs.flutter.dev/desktop을 참고하세요.

3. 시작하기

개발 환경 확인

개발에 필요한 모든 것이 준비되었는지 가장 쉽게 확인하려면 다음 명령어를 실행해 보세요.

$ flutter doctor

체크표시가 없는 항목이 있다면 다음 명령어를 실행하여 무엇이 문제인지 자세히 알아보세요.

$ flutter doctor -v

모바일이나 데스크톱에서 개발하기 위해 개발자 도구를 설치해야 할 수도 있습니다. 호스트 운영체제에 따라 도구를 구성하는 자세한 방법은 Flutter 설치 문서에 있는 문서를 참고하세요.

Flutter 프로젝트 생성

데스크톱 앱용 Flutter 작성을 쉽게 시작하는 방법은 Flutter 명령줄 도구를 사용하여 Flutter 프로젝트를 만드는 것입니다. 또는 IDE에서 UI를 통해 Flutter 프로젝트를 만들기 위한 워크플로를 제공할 수도 있습니다.

$ flutter create adaptive_app
Creating project adaptive_app
[Eliding listing of created files]
Running "flutter pub get" in adaptive_app...                     2,445ms
Wrote 128 files.

All done!
In order to run your application, type:

  $ cd adaptive_app
  $ flutter run

Your application code is in adaptive_app/lib/main.dart.

모든 것이 잘 작동하는지 확인하려면 아래와 같이 상용구로 구성된 Flutter 애플리케이션을 모바일 앱으로 실행해 보세요. 또는 IDE에서 이 프로젝트를 열고 IDE 도구를 사용하여 애플리케이션을 실행합니다. 이전 단계로 인해 데스크톱 애플리케이션으로 실행하는 방법만 선택할 수 있습니다.

$ flutter run
Launching lib/main.dart on iPhone 12 in debug mode...
Running Xcode build...
 └─Compiling, linking and signing...                        15.9s
Xcode build done.                                           41.2s
Syncing files to device iPhone 12...                               213ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

💪 Running with sound null safety 💪

An Observatory debugger and profiler on iPhone 12 is available at: http://127.0.0.1:60071/t9hy0pnIWgE=/
Activating Dart DevTools...                                         3.3s
The Flutter DevTools debugger and profiler on iPhone 12 is available at:
http://127.0.0.1:9101?uri=http://127.0.0.1:60071/t9hy0pnIWgE=/

이제 앱이 실행되는 것을 확인할 수 있습니다. 다음과 같이 lib/main.dart의 콘텐츠를 수정하고 핫 리로드를 실행하여 콘텐츠를 업데이트합니다. 명령줄을 통해(콘솔 창에 'r' 입력) 앱을 실행하는지 또는 편집기를 통해(파일을 저장하는 것이 핫 리로드를 트리거하기에 충분하다고 생각되는 경우) 앱을 실행하는지에 따라 핫 리로드 실행 방법이 달라집니다.

lib/main.dart

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const ResizeablePage(),
    );
  }
}

class ResizeablePage extends StatelessWidget {
  const ResizeablePage({super.key});

  @override
  Widget build(BuildContext context) {
    final mediaQuery = MediaQuery.of(context);
    final themePlatform = Theme.of(context).platform;

    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Window properties',
              style: Theme.of(context).textTheme.headline5,
            ),
            const SizedBox(height: 8),
            SizedBox(
              width: 350,
              child: Table(
                textBaseline: TextBaseline.alphabetic,
                children: <TableRow>[
                  _fillTableRow(
                    context: context,
                    property: 'Window Size',
                    value: '${mediaQuery.size.width.toStringAsFixed(1)} x '
                        '${mediaQuery.size.height.toStringAsFixed(1)}',
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Device Pixel Ratio',
                    value: mediaQuery.devicePixelRatio.toStringAsFixed(2),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Platform.isXXX',
                    value: platformDescription(),
                  ),
                  _fillTableRow(
                    context: context,
                    property: 'Theme.of(ctx).platform',
                    value: themePlatform.toString(),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  TableRow _fillTableRow(
      {required BuildContext context,
      required String property,
      required String value}) {
    return TableRow(
      children: [
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(property),
          ),
        ),
        TableCell(
          verticalAlignment: TableCellVerticalAlignment.baseline,
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(value),
          ),
        ),
      ],
    );
  }

  String platformDescription() {
    if (kIsWeb) {
      return 'Web';
    } else if (Platform.isAndroid) {
      return 'Android';
    } else if (Platform.isIOS) {
      return 'iOS';
    } else if (Platform.isWindows) {
      return 'Windows';
    } else if (Platform.isMacOS) {
      return 'macOS';
    } else if (Platform.isLinux) {
      return 'Linux';
    } else if (Platform.isFuchsia) {
      return 'Fuchsia';
    } else {
      return 'Unknown';
    }
  }
}

위의 앱은 다양한 플랫폼을 탐지하는 방식과 플랫폼에 맞게 조정하는 방식을 알아볼 수 있도록 설계되었습니다. 다음은 Android와 iOS에서 기본적으로 실행되는 앱입니다.

다음은 동일한 코드가 기본적으로 macOS에서 실행되고 Chrome 내에서, 즉 macOS에서 다시 실행되는 모습입니다.

여기서 먼저 알아야 할 중요한 점은 언뜻 보기에는 Flutter가 실행되고 있는 디스플레이에 콘텐츠를 맞추기 위해 할 수 있는 일을 하고 있다는 것입니다. 이 스크린샷을 찍은 노트북에는 고해상도의 Mac 디스플레이가 있기 때문에 앱이 macOS 버전과 웹 버전 모두 기기 픽셀 비율 2에서 렌더링됐습니다. 하지만 이 비율은 iPhone 12에서는 3이고 Pixel 2에서는 2.63입니다. 모든 경우에 표시된 텍스트는 거의 비슷하므로 개발자의 작업이 매우 쉬워집니다.

두 번째로 알아야 하는 것은 코드가 실행되고 있는 플랫폼으로 인해 값이 달라지는 것을 확인할 수 있는 두 개의 옵션입니다. 첫 번째 옵션은 dart:io에서 가져온 Platform 객체를 검사하며 두 번째 옵션(위젯의 build 메서드에서만 사용 가능)은 BuildContext 인수에서 Theme 객체를 가져옵니다.

이러한 두 개의 메서드가 다른 결과를 반환하는 이유는 서로 의도가 다르기 때문입니다. dart:io에서 가져온 Platform 객체는 렌더링 선택과 독립된 결정을 하는 데 사용할 수 있음을 의미합니다. 이에 대한 가장 좋은 예로 사용할 플러그인을 결정하는 것을 들 수 있습니다. 이 플러그인은 실제 플랫폼별 네이티브 구현과 일치하거나 일치하지 않을 수 있습니다.

BuildContext에서 Theme을 추출하는 것은 테마 중심적인 구현 결정을 위한 것입니다. 이에 대한 가장 좋은 예는 Slider.adaptive에서 설명하는 대로 Material 슬라이더를 사용할지 Cupertino 슬라이더를 사용하지를 결정하는 것입니다.

다음 섹션에서는 순전히 Android와 iOS에 최적화된 기본 YouTube 재생목록 탐색기 앱을 빌드합니다. 이후의 섹션에서는 앱을 데스크톱과 웹에서 더 잘 작동하도록 하는 여러 가지 조정 방법을 추가합니다.

4. 모바일 앱 빌드

패키지 추가

이 앱에서는 다양한 Flutter 패키지를 사용하여 YouTube Data API, 상태 관리, 테마 설정 일부에 액세스하게 됩니다.

$ flutter pub add googleapis
Resolving dependencies...
+ _discoveryapis_commons 1.0.3
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ googleapis 9.1.0
+ http 0.13.4
+ http_parser 4.0.1
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
+ typed_data 1.3.1
Changed 5 dependencies!

첫 번째 패키지 googleapisGoogle API에 액세스하기 위해 생성된 Dart 라이브러리입니다.

$ flutter pub add http
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Got dependencies!

http 패키지는 API 키를 사용하여 YouTube Data API에 액세스하는 기능을 구축하는 데 필수입니다.

$ flutter pub add provider
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
+ nested 1.0.0
  path 1.8.1 (1.8.2 available)
+ provider 6.0.3
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Changed 2 dependencies!

상태 관리에는 provider를 사용합니다.

$ flutter pub add url_launcher
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ js 0.6.4
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
+ plugin_platform_interface 2.1.2
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
+ url_launcher 6.1.4
+ url_launcher_android 6.0.17
+ url_launcher_ios 6.0.17
+ url_launcher_linux 3.0.1
+ url_launcher_macos 3.0.1
+ url_launcher_platform_interface 2.1.0
+ url_launcher_web 2.0.12
+ url_launcher_windows 3.0.1
Changed 11 dependencies!

재생목록에 있는 동영상을 시작하는 방법으로는 url_launcher를 사용합니다. 결정된 종속 항목에서 볼 수 있는 것처럼 url_launcher에는 기본 Android와 iOS 외에 Windows, macOS, Linux, 웹에 대한 구현이 포함되어 있습니다. 이는 개발자가 플랫폼별 코드를 만들 필요가 없는 기능 중 하나입니다.

$ flutter pub add flex_color_scheme
Resolving dependencies...
  async 2.8.2 (2.9.0 available)
  characters 1.2.0 (1.2.1 available)
  clock 1.1.0 (1.1.1 available)
  fake_async 1.3.0 (1.3.1 available)
+ flex_color_scheme 5.1.0
  matcher 0.12.11 (0.12.12 available)
  material_color_utilities 0.1.4 (0.1.5 available)
  meta 1.7.0 (1.8.0 available)
  path 1.8.1 (1.8.2 available)
  source_span 1.8.2 (1.9.0 available)
  string_scanner 1.1.0 (1.1.1 available)
  term_glyph 1.2.0 (1.2.1 available)
  test_api 0.4.9 (0.4.12 available)
Changed 1 dependency!m

이 패키지는 순수하게 앱에 멋진 기본 색 구성표를 제공합니다. 전체 기능을 이해하려면 flex_color_scheme 문서를 참고하세요.

$ flutter pub add go_router
Resolving dependencies...
  async 2.9.0 (2.10.0 available)
  boolean_selector 2.1.0 (2.1.1 available)
  collection 1.16.0 (1.17.0 available)
+ flutter_web_plugins 0.0.0 from sdk flutter
+ go_router 5.2.0
+ js 0.6.4 (0.6.5 available)
+ logging 1.1.0
  matcher 0.12.12 (0.12.13 available)
  material_color_utilities 0.1.5 (0.2.0 available)
  source_span 1.9.0 (1.9.1 available)
  stack_trace 1.10.0 (1.11.0 available)
  stream_channel 2.1.0 (2.1.1 available)
  string_scanner 1.1.1 (1.2.0 available)
  test_api 0.4.12 (0.4.16 available)
  vector_math 2.1.2 (2.1.4 available)
Changed 4 dependencies!

다양한 화면 간의 탐색을 구현하려면 프로젝트에 go_router를 추가합니다.

이 패키지는 Flutter의 Router를 사용하여 탐색하는 데 필요한 URL 기반의 편리한 API를 제공합니다.

url_launcher를 위한 모바일 앱 구성

url_launcher 플러그인은 Android와 iOS 실행기 애플리케이션 구성이 필요합니다. iOS Flutter 실행기에서 plist 사전에 다음 줄을 추가하세요.

ios/Runner/Info.plist

<key>LSApplicationQueriesSchemes</key>
<array>
        <string>https</string>
        <string>http</string>
        <string>tel</string>
        <string>mailto</string>
</array>

Android Flutter 실행기에서 Manifest.xml에 다음 줄을 추가합니다. 이 queries 노드를 manifest 노드의 직계 하위 요소 및 application 노드와 동일한 수준의 요소로 추가합니다.

android/app/src/main/AndroidManifest.xml

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.intent.action.DIAL" />
        <data android:scheme="tel" />
    </intent>
    <intent>
        <action android:name="android.intent.action.SEND" />
        <data android:mimeType="*/*" />
    </intent>
</queries>

이러한 필수 구성 변경사항에 관한 자세한 내용은 url_launcher 문서를 참고하세요.

YouTube Data API 액세스

YouTube Data API에 액세스하여 재생목록을 나열하려면 API 프로젝트를 만들어 필수 API 키를 생성해야 합니다. 이러한 단계는 개발자가 이미 Google 계정을 가지고 있다고 가정하므로 아직 없다면 계정을 만들어야 합니다.

Play Console로 이동하여 API 프로젝트를 만듭니다.

7fe39926b91104c3.png

프로젝트를 만든 후에는 API 라이브러리 페이지로 이동합니다. 검색창에 'youtube'를 입력하고 youtube data api v3을 선택합니다.

26ac7d6164430ece.png

YouTube Data API v3 세부정보 페이지에서 API를 사용 설정합니다.

5a877ea82b83ae42.png

API를 사용 설정한 후에는 사용자 인증 정보 페이지로 이동하여 API 키를 만듭니다.

a75ba6e17bef352.png

몇 초 뒤 새로운 API 키가 대화상자에 표시됩니다. 이 키는 곧바로 사용할 예정입니다.

d808e4a25d448ecc.png

코드 추가

이 단계의 나머지 부분에서는 코드에 대한 설명 없이 많은 코드를 잘라서 붙여넣는 방식으로 모바일 앱을 빌드합니다. 이 Codelab의 의도는 모바일 앱을 가져와서 데스크톱과 웹 모두에 맞게 조정하는 것입니다. 모바일용 Flutter 앱 빌드에 관한 자세한 내용은 첫 번째 Flutter 앱 작성, 파트 1, 파트 2, Flutter로 멋진 UI 빌드를 참고하세요.

먼저 다음 파일, 즉 상태 객체를 앱에 추가합니다.

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class FlutterDevPlaylists extends ChangeNotifier {
  FlutterDevPlaylists({
    required String flutterDevAccountId,
    required String youTubeApiKey,
  }) : _flutterDevAccountId = flutterDevAccountId {
    _api = YouTubeApi(
      _ApiKeyClient(
        client: http.Client(),
        key: youTubeApiKey,
      ),
    );
    _loadPlaylists();
  }

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api.playlists.list(
        ['snippet', 'contentDetails', 'id'],
        channelId: _flutterDevAccountId,
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort((a, b) => a.snippet!.title!
          .toLowerCase()
          .compareTo(b.snippet!.title!.toLowerCase()));
      notifyListeners();
    } while (nextPageToken != null);
  }

  final String _flutterDevAccountId;
  late final YouTubeApi _api;

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api.playlistItems.list(
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

class _ApiKeyClient extends http.BaseClient {
  _ApiKeyClient({required this.key, required this.client});

  final String key;
  final http.Client client;

  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    final url = request.url.replace(queryParameters: <String, List<String>>{
      ...request.url.queryParametersAll,
      'key': [key]
    });

    return client.send(http.Request(request.method, url));
  }
}

다음으로 개별 재생목록 세부정보 페이지를 추가합니다.

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(playlistName),
      ),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, playlists, _) {
          final playlistItems = playlists.playlistItems(playlistId: playlistId);
          if (playlistItems.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }

          return _PlaylistDetailsListView(playlistItems: playlistItems);
        },
      ),
    );
  }
}

class _PlaylistDetailsListView extends StatelessWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  final List<PlaylistItem> playlistItems;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyText2!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        Link(
          uri: Uri.parse(
              'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
          builder: (context, followLink) => IconButton(
            onPressed: followLink,
            color: Colors.red,
            icon: const Icon(Icons.play_circle_fill),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

그런 다음, 재생목록의 목록을 추가합니다.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('FlutterDev Playlists'),
      ),
      body: Consumer<FlutterDevPlaylists>(
        builder: (context, flutterDev, child) {
          final playlists = flutterDev.playlists;
          if (playlists.isEmpty) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          return _PlaylistsListView(items: playlists);
        },
      ),
    );
  }
}

class _PlaylistsListView extends StatelessWidget {
  const _PlaylistsListView({required this.items});

  final List<Playlist> items;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: items.length,
      itemBuilder: (context, index) {
        var playlist = items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              context.go(
                Uri(
                  path: '/playlist/${playlist.id}',
                  queryParameters: <String, String>{
                    'title': playlist.snippet!.title!
                  },
                ).toString(),
              );
            },
          ),
        );
      },
    );
  }
}

main.dart 파일의 콘텐츠를 다음의 내용으로 교체합니다.

lib/main.dart

import 'dart:io';

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'src/app_state.dart';
import 'src/playlist_details.dart';
import 'src/playlists.dart';

// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const Playlists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return PlaylistDetails(
              playlistId: id,
              playlistName: title,
            );
          },
        ),
      ],
    ),
  ],
);

void main() {
  if (youTubeApiKey == 'AIzaNotAnApiKey') {
    print('youTubeApiKey has not been configured.');
    exit(1);
  }

  runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
    create: (context) => FlutterDevPlaylists(
      flutterDevAccountId: flutterDevAccountId,
      youTubeApiKey: youTubeApiKey,
    ),
    child: const PlaylistsApp(),
  ));
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'FlutterDev Playlists',
      theme: FlexColorScheme.light(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

이제 Android와 iOS에서 이 코드를 실행할 준비가 거의 다 되었습니다. 한 가지만 더 변경하면 됩니다. 14번 줄youTubeApiKey 상수를 이전 단계에서 생성한 YouTube API 키로 수정합니다.

lib/main.dart

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

macOS에서 이 앱을 실행하려면 다음과 같이 앱을 사용 설정하여 HTTP 요청을 만들어야 합니다. 아래와 같이 DebugProfile.entitlementsRelease.entitilements를 모두 수정하세요.

macos/Runner/DebugProfile.entitlements

<?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.security.app-sandbox</key>
                <true/>
                <key>com.apple.security.cs.allow-jit</key>
                <true/>
                <key>com.apple.security.network.server</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

macos/Runner/Release.entitlements

<?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.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

앱 실행

이제 완전한 애플리케이션이 있으므로 Android Emulator나 iPhone 시뮬레이터에서 정상적으로 실행할 수 있습니다. Flutter의 재생목록 목록이 표시됩니다. 재생목록을 선택하면 재생목록에 동영상이 표시되고 최종적으로 재생 버튼을 클릭하면 YouTube 환경이 시작되면서 동영상을 시청하게 됩니다.

하지만, 데스크톱에서 이 앱을 실행하려고 하면 일반적인 데스크톱 크기의 창으로 레이아웃을 확장할 때 이상하게 표시됩니다. 다음 단계에서는 데스크톱에 맞게 조정하는 방법을 자세히 알아봅니다.

5. 데스크톱에 맞게 조정

데스크톱의 문제

Windows, macOS, Linux와 같은 네이티브 데스크톱 플랫폼 중 하나에서 앱을 실행하면 흥미로운 문제를 발견하게 됩니다. 앱이 작동은 하지만, 이상하게 보인다는 것입니다.

이 문제를 해결하기 위해 왼쪽에는 재생목록을 나열하고 오른쪽에는 동영상을 표시하는 분할 보기를 추가합니다. 하지만, 코드가 Android나 iOS에서 실행되지 않고 창이 충분히 넓은 경우에만 이 레이아웃을 사용하려고 합니다. 다음 안내는 이 기능을 구현하는 방법을 보여줍니다.

먼저, 레이아웃 구성을 돕기 위해 split_view 패키지를 추가합니다.

$ flutter pub add split_view
Resolving dependencies...
+ split_view 3.1.0
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

적응형 위젯 사용

이 Codelab에서 사용하려는 방법은 화면 너비, 플랫폼 테마 등과 같은 속성에 따라 구현을 선택하는 적응형 위젯을 도입하는 것입니다. 이 경우에는 PlaylistsPlaylistDetails가 상호작용하는 방식을 다시 작성하는 AdaptivePlaylists 위젯을 사용합니다. 다음과 같이 lib/main.dart 파일을 수정합니다.

lib/main.dart

import 'dart:io';

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';

import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';

// From https://www.youtube.com/channel/UCwXdFgeE9KYzlDdR7TG9cMw
const flutterDevAccountId = 'UCwXdFgeE9KYzlDdR7TG9cMw';

// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      routes: <RouteBase>[
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(
                playlistId: id,
                playlistName: title,
              ),
            );
          },
        ),
      ],
    ),
  ],
);

void main() {
  if (youTubeApiKey == 'AIzaNotAnApiKey') {
    print('youTubeApiKey has not been configured.');
    exit(1);
  }

  runApp(ChangeNotifierProvider<FlutterDevPlaylists>(
    create: (context) => FlutterDevPlaylists(
      flutterDevAccountId: flutterDevAccountId,
      youTubeApiKey: youTubeApiKey,
    ),
    child: const PlaylistsApp(),
  ));
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'FlutterDev Playlists',
      theme: FlexColorScheme.light(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

다음으로 다음과 같이 AdaptivePlaylist 위젯을 위한 파일을 만듭니다.

lib/src/adaptive_playlists.dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:split_view/split_view.dart';

import 'playlist_details.dart';
import 'playlists.dart';

class AdaptivePlaylists extends StatelessWidget {
  const AdaptivePlaylists({super.key});

  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    final targetPlatform = Theme.of(context).platform;

    if (targetPlatform == TargetPlatform.android ||
        targetPlatform == TargetPlatform.iOS ||
        screenWidth <= 600) {
      return const NarrowDisplayPlaylists();
    } else {
      return const WideDisplayPlaylists();
    }
  }
}

class NarrowDisplayPlaylists extends StatelessWidget {
  const NarrowDisplayPlaylists({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('FlutterDev Playlists')),
      body: Playlists(
        playlistSelected: (playlist) {
          context.go(
            Uri(
              path: '/playlist/${playlist.id}',
              queryParameters: <String, String>{
                'title': playlist.snippet!.title!
              },
            ).toString(),
          );
        },
      ),
    );
  }
}

class WideDisplayPlaylists extends StatefulWidget {
  const WideDisplayPlaylists({super.key});

  @override
  State<WideDisplayPlaylists> createState() => _WideDisplayPlaylistsState();
}

class _WideDisplayPlaylistsState extends State<WideDisplayPlaylists> {
  Playlist? selectedPlaylist;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: selectedPlaylist == null
            ? const Text('FlutterDev Playlists')
            : Text('FlutterDev Playlist: ${selectedPlaylist!.snippet!.title!}'),
      ),
      body: SplitView(
        viewMode: SplitViewMode.Horizontal,
        children: [
          Playlists(playlistSelected: (playlist) {
            setState(() {
              selectedPlaylist = playlist;
            });
          }),
          if (selectedPlaylist != null)
            PlaylistDetails(
                playlistId: selectedPlaylist!.id!,
                playlistName: selectedPlaylist!.snippet!.title!)
          else
            const Center(
              child: Text('Select a playlist'),
            ),
        ],
      ),
    );
  }
}

이 파일은 몇 가지 이유에서 흥미롭습니다. 첫째, SplitView 위젯을 사용하여 넓은 레이아웃을 표시할지 아니면 위젯을 사용하지 않고 좁은 디스플레이를 표시할지 결정하는 데 창의 너비(MediaQuery.of(context).size.width 사용)를 사용하면서 테마(Theme.of(context).platform 사용)도 검사한다는 점입니다.

두 번째로 참고할 점은 이전에 하드 코딩된 탐색 처리 방식을 사용하고 있다는 것입니다. 이 방식은 주변 코드에 사용자가 재생목록을 선택했다고 알려주고 선택한 재생목록을 표시하는 데 필요한 작업은 무엇이든 실행해야 하는 Playlists 위젯의 콜백 인수를 표시하여 처리됩니다. 또한, 이제 ScaffoldPlaylistsPlaylistDetails 위젯에서 제외되었으며 이러한 위젯은 최상위 수준에 있지 않습니다.

다음으로, 아래와 같이 src/lib/playlists.dart 파일을 수정합니다.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    required this.items,
    required this.playlistSelected,
  });

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: Image.network(
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

이 파일에는 많은 변경사항이 있습니다. 앞서 언급한 playlistSelected 콜백의 사용과 Scaffold 위젯의 삭제 외에 _PlaylistsListView 위젯이 스테이트리스(Stateless)에서 스테이트풀(Stateful)로 변환됩니다. 이러한 변경은 생성 및 소멸되어야 하는 자체 ScrollController를 사용하기 때문에 필요합니다.

넓은 레이아웃에 두 개의 ListView 위젯이 나란히 있다는 사실 때문에 ScrollController를 사용해야 한다는 점이 흥미롭습니다. 휴대전화에는 일반적으로 ListView가 하나만 있으므로 각각의 수명 주기 동안 모든 ListView가 연결되고 연결 해제되는 하나의 장기 ScrollController가 있으면 됩니다. 데스크톱은 여러 ListView가 나란히 있을 수 있다는 점에서 다릅니다.

마지막으로 lib/src/playlist_details.dart 파일을 다음과 같이 수정합니다.

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, playlists, _) {
        final playlistItems = playlists.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  final List<PlaylistItem> playlistItems;

  @override
  State<_PlaylistDetailsListView> createState() =>
      _PlaylistDetailsListViewState();
}

class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = widget.playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  Image.network(playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
            Text(
              playlistItem.snippet!.videoOwnerChannelTitle!,
              style: Theme.of(context).textTheme.bodyText2!.copyWith(
                    fontSize: 12,
                  ),
            ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        Link(
          uri: Uri.parse(
              'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
          builder: (context, followLink) => IconButton(
            onPressed: followLink,
            color: Colors.red,
            icon: const Icon(Icons.play_circle_fill),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

위의 Playlists 위젯과 마찬가지로 이 파일에는 Scaffold 위젯 제거 및 자체 ScrollController 사용과 관련된 변경사항이 있습니다.

앱을 다시 실행

Windows, macOS, Linux 중 선택한 데스크톱에서 앱을 실행합니다. 예상한 대로 앱이 작동해야 합니다.

c356b0976c708cdb.png

6. 웹에 맞게 조정

이미지에 무슨 일이 있는 것일까요?

이 앱을 현재 상태로 웹에서 실행하려고 하면 이전 단계에서 레이아웃을 변경했음에도 웹브라우저 환경에 맞추기 위한 작업이 남아있는 것을 볼 수 있습니다.

2413ac49488025b4.png

디버그 콘솔을 살펴보면 다음에 해야 할 작업에 관한 유용한 힌트를 얻을 수 있습니다.

════════ Exception caught by image resource service ════════════════════════════
Failed to load network image.
Image URL: https://i.ytimg.com/vi/QIW35-vcA2o/default.jpg
Trying to load an image from another domain? Find answers at:
https://flutter.dev/docs/development/platform-integration/web-images
═════════════════════════════════════════════════════════════════════════

CORS 프록시 생성

이미지 렌더링 문제를 처리하는 한 가지 방법은 프록시 웹 서비스를 도입하여 필수 교차 출처 리소스 공유 헤더에 추가하는 것입니다. 터미널을 열고 다음과 같이 Dart 웹 서버를 만듭니다.

$ dart create --template server-shelf yt_cors_proxy
Creating yt_cors_proxy using template server-shelf...

  .gitignore
  analysis_options.yaml
  CHANGELOG.md
  pubspec.yaml
  README.md
  Dockerfile
  .dockerignore
  test/server_test.dart
  bin/server.dart

Running pub get...                     3.9s
  Resolving dependencies...
  Changed 53 dependencies!

Created project yt_cors_proxy in yt_cors_proxy! In order to get started, run the following commands:

  cd yt_cors_proxy
  dart run bin/server.dart

디렉터리를 yt_cors_proxy 서버로 바꾸고 필수 종속 항목을 몇 개 추가합니다.

$ cd yt_cors_proxy
$ dart pub add shelf_cors_headers
Resolving dependencies...
+ shelf_cors_headers 0.1.2
Changed 1 dependency!
$ dart pub add http
"http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead.
Resolving dependencies...
Got dependencies!

현재 더 이상 사용하지 않는 종속 항목이 몇 개 있습니다. 다음과 같이 이러한 종속 항목을 삭제합니다.

$ dart pub remove args
Resolving dependencies...
These packages are no longer being depended on:
- args 2.2.0
Changed 1 dependency!
$ dart pub remove shelf_router
Resolving dependencies...
These packages are no longer being depended on:
- http_methods 1.1.0
- shelf_router 1.1.1
Changed 2 dependencies!

다음으로 아래와 일치하도록 server.dart 파일의 콘텐츠를 수정합니다.

yt_cors_proxy/bin/server.dart

import 'dart:async';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_cors_headers/shelf_cors_headers.dart';

Future<Response> _requestHandler(Request req) async {
  final target = req.url.replace(scheme: 'https', host: 'i.ytimg.com');
  final response = await http.get(target);
  return Response.ok(response.bodyBytes, headers: response.headers);
}

void main(List<String> args) async {
  // Use any available host or container IP (usually `0.0.0.0`).
  final ip = InternetAddress.anyIPv4;

  // Configure a pipeline that adds CORS headers and proxies requests.
  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addMiddleware(corsHeaders(headers: {ACCESS_CONTROL_ALLOW_ORIGIN: '*'}))
      .addHandler(_requestHandler);

  // For running in containers, we respect the PORT environment variable.
  final port = int.parse(Platform.environment['PORT'] ?? '8080');
  final server = await serve(handler, ip, port);
  print('Server listening on port ${server.port}');
}

다음과 같이 이 서버를 실행할 수 있습니다.

$ dart run bin/server.dart
Server listening on port 8080

또는 다음과 같이 서버를 Docker 이미지로 빌드하고 빌드 결과로 나온 Docker 이미지를 실행하면 됩니다.

$ docker build . -t yt-cors-proxy
[+] Building 2.7s (14/14) FINISHED
$ docker run -p 8080:8080 yt-cors-proxy
Server listening on port 8080

그런 다음 이 CORS 프록시를 활용하도록 Flutter 코드를 수정합니다. 단, 웹브라우저 내에서 실행하는 경우에 한합니다.

한 쌍의 적응형 위젯

두 개의 위젯 중 첫 번째 위젯은 앱이 CORS 프록시를 사용하는 방식입니다.

lib/src/adaptive_image.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class AdaptiveImage extends StatelessWidget {
  AdaptiveImage.network(String url, {super.key}) {
    if (kIsWeb) {
      _url = Uri.parse(url)
          .replace(host: 'localhost', port: 8080, scheme: 'http')
          .toString();
    } else {
      _url = url;
    }
  }

  late final String _url;

  @override
  Widget build(BuildContext context) {
    return Image.network(_url);
  }
}

흥미로운 점은 테마를 설정했기 때문에 차이가 없지만 대신 런타임 플랫폼으로 인해 차이가 있어서 kIsWeb을 사용하고 있다는 점입니다. 다른 적응형 위젯은 웹브라우저 사용자가 텍스트를 선택할 수 있다고 예상한다는 사실을 다룹니다.

lib/src/adaptive_text.dart

import 'package:flutter/material.dart';

class AdaptiveText extends StatelessWidget {
  const AdaptiveText(this.data, {super.key, this.style});
  final String data;
  final TextStyle? style;

  @override
  Widget build(BuildContext context) {
    switch (Theme.of(context).platform) {
      case TargetPlatform.android:
      case TargetPlatform.iOS:
        return Text(data, style: style);
      default:
        return SelectableText(data, style: style);
    }
  }
}

이제 코드베이스를 통해 이러한 조정 내용을 배포합니다.

lib/src/playlist_details.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'adaptive_image.dart';   // Add this line,
import 'adaptive_text.dart';    // and this line
import 'app_state.dart';

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, playlists, _) {
        final playlistItems = playlists.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

class _PlaylistDetailsListView extends StatefulWidget {
  const _PlaylistDetailsListView({required this.playlistItems});
  final List<PlaylistItem> playlistItems;

  @override
  State<_PlaylistDetailsListView> createState() =>
      _PlaylistDetailsListViewState();
}

class _PlaylistDetailsListViewState extends State<_PlaylistDetailsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.playlistItems.length,
      itemBuilder: (context, index) {
        final playlistItem = widget.playlistItems[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ClipRRect(
            borderRadius: BorderRadius.circular(4),
            child: Stack(
              alignment: Alignment.center,
              children: [
                if (playlistItem.snippet!.thumbnails!.high != null)
                  AdaptiveImage.network(    // Modify this line
                      playlistItem.snippet!.thumbnails!.high!.url!),
                _buildGradient(context),
                _buildTitleAndSubtitle(context, playlistItem),
                _buildPlayButton(context, playlistItem),
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildGradient(BuildContext context) {
    return Positioned.fill(
      child: DecoratedBox(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.transparent, Theme.of(context).backgroundColor],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            stops: const [0.5, 0.95],
          ),
        ),
      ),
    );
  }

  Widget _buildTitleAndSubtitle(
      BuildContext context, PlaylistItem playlistItem) {
    return Positioned(
      left: 20,
      right: 0,
      bottom: 20,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          AdaptiveText(    // This line
            playlistItem.snippet!.title!,
            style: Theme.of(context).textTheme.bodyText1!.copyWith(
                  fontSize: 18,
                  // fontWeight: FontWeight.bold,
                ),
          ),
          if (playlistItem.snippet!.videoOwnerChannelTitle != null)
          AdaptiveText(    // And, this line
            playlistItem.snippet!.videoOwnerChannelTitle!,
            style: Theme.of(context).textTheme.bodyText2!.copyWith(
                  fontSize: 12,
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildPlayButton(BuildContext context, PlaylistItem playlistItem) {
    return Stack(
      alignment: AlignmentDirectional.center,
      children: [
        Container(
          width: 42,
          height: 42,
          decoration: const BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.all(
              Radius.circular(21),
            ),
          ),
        ),
        Link(
          uri: Uri.parse(
              'https://www.youtube.com/watch?v=${playlistItem.snippet!.resourceId!.videoId}'),
          builder: (context, followLink) => IconButton(
            onPressed: followLink,
            color: Colors.red,
            icon: const Icon(Icons.play_circle_fill),
            iconSize: 45,
          ),
        ),
      ],
    );
  }
}

위의 코드에서는 Image.networkText 위젯을 모두 조정했습니다. 다음으로 Playlists 위젯을 조정합니다.

lib/src/playlists.dart

import 'package:flutter/material.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:provider/provider.dart';

import 'adaptive_image.dart'; // Add this line
import 'app_state.dart';

class Playlists extends StatelessWidget {
  const Playlists({super.key, required this.playlistSelected});

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<FlutterDevPlaylists>(
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

typedef PlaylistsListSelected = void Function(Playlist playlist);

class _PlaylistsListView extends StatefulWidget {
  const _PlaylistsListView({
    required this.items,
    required this.playlistSelected,
  });

  final List<Playlist> items;
  final PlaylistsListSelected playlistSelected;

  @override
  State<_PlaylistsListView> createState() => _PlaylistsListViewState();
}

class _PlaylistsListViewState extends State<_PlaylistsListView> {
  late ScrollController _scrollController;

  @override
  void initState() {
    super.initState();
    _scrollController = ScrollController();
  }

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      controller: _scrollController,
      itemCount: widget.items.length,
      itemBuilder: (context, index) {
        var playlist = widget.items[index];
        return Padding(
          padding: const EdgeInsets.all(8.0),
          child: ListTile(
            leading: AdaptiveImage.network(  // Change this one.
              playlist.snippet!.thumbnails!.default_!.url!,
            ),
            title: Text(playlist.snippet!.title!),
            subtitle: Text(
              playlist.snippet!.description!,
            ),
            onTap: () {
              widget.playlistSelected(playlist);
            },
          ),
        );
      },
    );
  }
}

이번에는 Image.network 위젯만 조정했으며, 두 개의 Text 위젯이 그대로 남아있습니다. 이는 의도한 것이었습니다. 왜냐하면 Text 위젯을 조정할 경우 사용자가 텍스트를 탭할 때 ListTileonTap 기능이 차단되기 때문입니다.

웹에서 제대로 앱 실행

CORS 프록시가 실행 중일 때 앱의 웹 버전을 실행할 수 있어야 하고 다음과 같은 모습이어야 합니다.

1e4f272524ebedb0.png

7. 적응형 인증

이 단계에서는 앱에 사용자 인증 기능을 부여하여 앱을 확장한 다음 사용자의 재생목록을 보여줍니다. 앱이 실행될 수 있는 다양한 플랫폼을 다루려면 여러 플러그인을 사용해야 합니다. OAuth를 처리하는 방식이 Android, iOS, 웹, Windows, macOS, Linux에서 매우 다르기 때문입니다.

플러그인을 추가하여 Google 인증 사용 설정

Google 인증을 처리하기 위해 3개의 패키지를 설치할 예정입니다.

$ flutter pub add googleapis_auth
Resolving dependencies...
+ crypto 3.0.1
+ googleapis_auth 1.3.0
  test_api 0.4.3 (0.4.8 available)
Changed 2 dependencies!
$ flutter pub add google_sign_in
Resolving dependencies...
+ google_sign_in 5.2.1
+ google_sign_in_platform_interface 2.1.0
+ google_sign_in_web 0.10.0+3
+ quiver 3.0.1+1
  test_api 0.4.3 (0.4.8 available)
Changed 4 dependencies!
$ flutter pub add extension_google_sign_in_as_googleapis_auth
Resolving dependencies...
+ extension_google_sign_in_as_googleapis_auth 2.0.4
  test_api 0.4.3 (0.4.8 available)
Changed 1 dependency!

사용자의 웹브라우저를 사용하여 Windows, macOS, Linux에서 인증할 수 있도록 googleapis_auth를 사용합니다. Android, iOS, 웹에서는 두 패키지 사이의 상호운용을 위해 연결 고리 역할을 하는 extension_google_sign_in_as_googleapis_auth와 함께 google_sign_in을 사용합니다.

코드 업데이트

재사용 가능한 새로운 추상화인 AdaptiveLogin 위젯을 만들어 업데이트를 시작합니다. 이 위젯은 재사용할 수 있도록 설계되었으며 다음과 같은 몇 가지 구성이 필요합니다.

lib/src/adaptive_login.dart

import 'dart:io' show Platform;

import 'package:extension_google_sign_in_as_googleapis_auth/extension_google_sign_in_as_googleapis_auth.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:googleapis_auth/auth_io.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';

import 'app_state.dart';

typedef _AdaptiveLoginButtonWidget = Widget Function({
  required VoidCallback? onPressed,
});

class AdaptiveLogin extends StatelessWidget {
  const AdaptiveLogin({
    super.key,
    required this.clientId,
    required this.scopes,
    required this.loginButtonChild,
  });

  final ClientId clientId;
  final List<String> scopes;
  final Widget loginButtonChild;

  @override
  Widget build(BuildContext context) {
    if (kIsWeb || Platform.isAndroid || Platform.isIOS) {
      return _GoogleSignInLogin(
        button: _loginButton,
        scopes: scopes,
      );
    } else {
      return _GoogleApisAuthLogin(
        button: _loginButton,
        scopes: scopes,
        clientId: clientId,
      );
    }
  }

  Widget _loginButton({required VoidCallback? onPressed}) => ElevatedButton(
        onPressed: onPressed,
        child: loginButtonChild,
      );
}

class _GoogleSignInLogin extends StatefulWidget {
  const _GoogleSignInLogin({
    required this.button,
    required this.scopes,
  });

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;

  @override
  State<_GoogleSignInLogin> createState() => _GoogleSignInLoginState();
}

class _GoogleSignInLoginState extends State<_GoogleSignInLogin> {
  @override
  initState() {
    super.initState();
    _googleSignIn = GoogleSignIn(
      scopes: widget.scopes,
    );
    _googleSignIn.onCurrentUserChanged.listen((account) {
      if (account != null) {
        _googleSignIn.authenticatedClient().then((authClient) {
          if (authClient != null) {
            context.read<AuthedUserPlaylists>().authClient = authClient;
            context.go('/');
          }
        });
      }
    });
  }

  late final GoogleSignIn _googleSignIn;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: widget.button(onPressed: () {
          _googleSignIn.signIn();
        }),
      ),
    );
  }
}

class _GoogleApisAuthLogin extends StatefulWidget {
  const _GoogleApisAuthLogin({
    required this.button,
    required this.scopes,
    required this.clientId,
  });

  final _AdaptiveLoginButtonWidget button;
  final List<String> scopes;
  final ClientId clientId;

  @override
  State<_GoogleApisAuthLogin> createState() => _GoogleApisAuthLoginState();
}

class _GoogleApisAuthLoginState extends State<_GoogleApisAuthLogin> {
  @override
  initState() {
    super.initState();
    clientViaUserConsent(widget.clientId, widget.scopes, (url) {
      setState(() {
        _authUrl = Uri.parse(url);
      });
    }).then((authClient) {
      context.read<AuthedUserPlaylists>().authClient = authClient;
      context.go('/');
    });
  }

  Uri? _authUrl;

  @override
  Widget build(BuildContext context) {
    final authUrl = _authUrl;
    if (authUrl != null) {
      return Scaffold(
        body: Center(
          child: Link(
            uri: authUrl,
            builder: (context, followLink) =>
                widget.button(onPressed: followLink),
          ),
        ),
      );
    }

    return const Scaffold(
      body: Center(
        child: CircularProgressIndicator(),
      ),
    );
  }
}

이 파일에는 진행할 내용이 많습니다. 중요한 점은 AdaptiveLoginbuild 메서드에서 kIsWeb의 조합과 dart:ioPlatform.isXXX 호출을 사용하여 런타임 플랫폼을 확인하며 Android, iOS, 웹의 경우에는 _GoogleSignInLogin 스테이트풀 위젯을 인스턴스화하고 Windows, macOS, Linux에서는 _GoogleApisAuthLogin 스테이트풀 위젯을 생성한다는 것입니다.

이 새로운 위젯을 사용하기 위해 나머지 코드베이스를 업데이트한 후 이러한 클래스를 사용하려면 이후에 나오는 추가 구성이 필요합니다. 수명 주기 동안 새로운 목적을 잘 반영하기 위해 FlutterDevPlaylistsAuthedUserPlaylists로 이름을 바꾸고 코드를 업데이트하여 생성 후 이제 http.Client가 전달되었음을 반영합니다. 최종적으로 _ApiKeyClient 클래스는 더 이상 필요하지 않습니다.

lib/src/app_state.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';
import 'package:googleapis/youtube/v3.dart';
import 'package:http/http.dart' as http;

class AuthedUserPlaylists extends ChangeNotifier {  // Rename class
  set authClient(http.Client client) { // Drop constructor, add setter
    _api = YouTubeApi(client);
    _loadPlaylists();
  }

  bool get isLoggedIn => _api != null; // Add property

  Future<void> _loadPlaylists() async {
    String? nextPageToken;
    _playlists.clear();

    do {
      final response = await _api!.playlists.list(  // Add ! to _api
        ['snippet', 'contentDetails', 'id'],
        mine: true,   // convert from channelId: to mine:
        maxResults: 50,
        pageToken: nextPageToken,
      );
      _playlists.addAll(response.items!);
      _playlists.sort((a, b) => a.snippet!.title!
          .toLowerCase()
          .compareTo(b.snippet!.title!.toLowerCase()));
      notifyListeners();
    } while (nextPageToken != null);
  }

  YouTubeApi? _api;    // Convert from late final to optional

  final List<Playlist> _playlists = [];
  List<Playlist> get playlists => UnmodifiableListView(_playlists);

  final Map<String, List<PlaylistItem>> _playlistItems = {};
  List<PlaylistItem> playlistItems({required String playlistId}) {
    if (!_playlistItems.containsKey(playlistId)) {
      _playlistItems[playlistId] = [];
      _retrievePlaylist(playlistId);
    }
    return UnmodifiableListView(_playlistItems[playlistId]!);
  }

  Future<void> _retrievePlaylist(String playlistId) async {
    String? nextPageToken;
    do {
      var response = await _api!.playlistItems.list(  // Add ! to _api
        ['snippet', 'contentDetails'],
        playlistId: playlistId,
        maxResults: 25,
        pageToken: nextPageToken,
      );
      var items = response.items;
      if (items != null) {
        _playlistItems[playlistId]!.addAll(items);
      }
      notifyListeners();
      nextPageToken = response.nextPageToken;
    } while (nextPageToken != null);
  }
}

// Delete the now unused _ApiKeyClient class

다음으로 제공된 애플리케이션 상태 객체를 위한 새 이름을 사용하여 PlaylistDetails 위젯을 업데이트합니다.

lib/src/playlist_details.dart

class PlaylistDetails extends StatelessWidget {
  const PlaylistDetails(
      {required this.playlistId, required this.playlistName, super.key});
  final String playlistId;
  final String playlistName;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(  // Update this line
      builder: (context, flutterDev, _) {
        final playlistItems = flutterDev.playlistItems(playlistId: playlistId);
        if (playlistItems.isEmpty) {
          return const Center(child: CircularProgressIndicator());
        }

        return _PlaylistDetailsListView(playlistItems: playlistItems);
      },
    );
  }
}

마찬가지로 Playlists 위젯도 업데이트합니다.

lib/src/playlists.dart

class Playlists extends StatelessWidget {
  const Playlists({required this.playlistSelected, super.key});

  final PlaylistsListSelected playlistSelected;

  @override
  Widget build(BuildContext context) {
    return Consumer<AuthedUserPlaylists>(  // Update this line
      builder: (context, flutterDev, child) {
        final playlists = flutterDev.playlists;
        if (playlists.isEmpty) {
          return const Center(
            child: CircularProgressIndicator(),
          );
        }

        return _PlaylistsListView(
          items: playlists,
          playlistSelected: playlistSelected,
        );
      },
    );
  }
}

마지막으로 main.dart 파일을 업데이트하여 새 AdaptiveLogin 위젯을 올바르게 사용합니다.

lib/main.dart

// Drop dart:io import

import 'package:flex_color_scheme/flex_color_scheme.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:googleapis_auth/googleapis_auth.dart'; // Add this line
import 'package:provider/provider.dart';

import 'src/adaptive_login.dart';                     // Add this line
import 'src/adaptive_playlists.dart';
import 'src/app_state.dart';
import 'src/playlist_details.dart';

// Drop flutterDevAccountId and youTubeApiKey

// Add from this line
// From https://developers.google.com/youtube/v3/guides/auth/installed-apps#identify-access-scopes
final scopes = [
  'https://www.googleapis.com/auth/youtube.readonly',
];

// TODO: Replace with your Client ID and Client Secret for Desktop configuration
final clientId = ClientId(
  'TODO-Client-ID.apps.googleusercontent.com',
  'TODO-Client-secret',
);
// To this line

final _router = GoRouter(
  routes: <RouteBase>[
    GoRoute(
      path: '/',
      builder: (context, state) {
        return const AdaptivePlaylists();
      },
      // Add redirect configuration
      redirect: (context, state) {
        if (!context.read<AuthedUserPlaylists>().isLoggedIn) {
          return '/login';
        } else {
          return null;
        }
      },
      // To this line
      routes: <RouteBase>[
        // Add new login Route
        GoRoute(
          path: 'login',
          builder: (context, state) {
            return AdaptiveLogin(
              clientId: clientId,
              scopes: scopes,
              loginButtonChild: const Text('Login to YouTube'),
            );
          },
        ),
        // To this line
        GoRoute(
          path: 'playlist/:id',
          builder: (context, state) {
            final title = state.queryParams['title']!;
            final id = state.params['id']!;
            return Scaffold(
              appBar: AppBar(title: Text(title)),
              body: PlaylistDetails(
                playlistId: id,
                playlistName: title,
              ),
            );
          },
        ),
      ],
    ),
  ],
);

void main() {
  runApp(ChangeNotifierProvider<AuthedUserPlaylists>(        // Modify this line
    create: (context) => AuthedUserPlaylists(), // Modify this line
    child: const PlaylistsApp(),
  ));
}

class PlaylistsApp extends StatelessWidget {
  const PlaylistsApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Your Playlists',            // Change FlutterDev to Your
      theme: FlexColorScheme.light(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      darkTheme: FlexColorScheme.dark(
        scheme: FlexScheme.red,
        useMaterial3: true,
      ).toTheme,
      themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
      debugShowCheckedModeBanner: false,
      routerConfig: _router,
    );
  }
}

이 파일의 변경사항은 단순히 Flutter의 YouTube 재생목록을 표시하는 것에서 인증된 사용자의 재생목록을 표시하는 것을 반영합니다. 이제 코드가 완성되었지만 인증용 패키지 google_sign_ingoogleapis_auth를 제대로 구성하기 위해 아직 이 파일과 각 실행기 앱 아래 파일에 필요한 일련의 수정사항이 남아있습니다.

googleapis_auth 구성

인증을 구성하는 첫 번째 단계는 이전에 구성하고 사용하던 API 키를 제거하는 것입니다. API 프로젝트의 사용자 인증 정보 페이지로 이동하여 API 키를 삭제합니다.

e7bf4977a5dcf985.png

이렇게 하면 삭제 버튼을 눌러 확인하는 팝업이 생성됩니다.

eb8b6787c2f2c951.png

그런 다음 OAuth 클라이언트 ID를 만듭니다.

af07105da9fc35d2.png

애플리케이션 유형으로 데스크톱 앱을 선택합니다.

1958672268c3283e.png

이름을 입력하고 만들기를 클릭합니다.

85c36e94f304f71f.png

이렇게 하면 googleapis_auth 흐름을 구성하기 위해 lib/main.dart에 추가해야 하는 클라이언트 ID와 클라이언트 보안 비밀번호가 생성됩니다. 중요한 구현 세부정보는 googleapis_auth 흐름이 생성된 OAuth 토큰을 캡처하기 위해 localhost에서 실행되는 임시 웹 서버를 사용한다는 점입니다. macOS의 경우 macos/Runner/Release.entitlements 파일을 수정해야 합니다.

macos/Runner/Release.entitlements

<?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.security.app-sandbox</key>
                <true/>
                <!-- add the following two lines -->
                <key>com.apple.security.network.server</key>
                <true/>
                <key>com.apple.security.network.client</key>
                <true/>
        </dict>
</plist>

이미 핫 리로드와 Dart VM 디버그 도구를 사용 설정하기 위해 com.apple.security.network.server의 사용 권한이 있기 때문에 이 수정사항을 macos/Runner/DebugProfile.entitlements 파일에 반영할 필요는 없습니다.

이제 앱이 Windows, macOS, Linux에서 실행되어야 합니다(앱이 이러한 타겟에서 컴파일된 경우).

4f323b032dd9a419.png

Android용 google_sign_in 구성

API 프로젝트의 사용자 인증 정보 페이지로 돌아가서 다른 OAuth 클라이언트 ID를 만들고 이번에는 Android를 선택합니다.

17687358b5a61a5b.png

양식의 나머지 부분의 경우 android/app/src/main/AndroidManifest.xml에 선언된 패키지로 패키지 이름을 채웁니다. 안내를 그대로 따랐다면 패키지 이름은 com.example.adaptive_app이어야 합니다. Google Cloud Platform Console 도움말 페이지의 안내를 사용하여 SHA-1 인증서 지문을 추출합니다.

45b22059ae417ce2.png

이 정도면 Android에서 작동하는 앱을 만들기에 충분합니다. 사용하는 Google API 선택에 따라 애플리케이션 번들에 생성된 JSON 파일을 추가해야 할 수도 있습니다.

4b4c03d9655b02c.png

iOS용 google_sign_in구성

API 프로젝트의 사용자 인증 정보 페이지로 돌아가서 다른 OAuth 클라이언트 ID를 만들고 이번에는 iOS를 선택합니다.

. 86a84eb772759f1f.png

양식의 나머지 부분의 경우 Xcode에서 ios/Runner.xcworkspace를 열어 번들 ID를 채웁니다. 프로젝트 탐색기로 이동하고 탐색기에서 실행기를 선택한 다음 일반 탭을 선택하여 번들 식별자를 복사합니다. 이 Codelab을 단계별로 따라왔다면 이 값은 com.example.adaptiveApp이어야 합니다.

7cda08d3408046a1.png

지금으로서는 앱 스토어 ID와 팀 ID가 로컬 개발에 필요하지 않으므로 무시합니다.

577c52bce54ad7c6.png

생성된 클라이언트 ID를 기반으로 이름이 지정된 생성된 .plist 파일을 다운로드합니다. 다운로드한 파일의 이름을 GoogleService-Info.plist로 바꾼 다음 탐색기 왼쪽 Runner/Runner 아래에 있는 Info.plist 파일과 함께 실행 중인 Xcode 편집기로 드래그합니다. Xcode의 옵션 대화상자에서 Copy items if needed(필요한 경우 항목 복사), Create folder references(폴더 참조 만들기), Add to the Runner(실행기에 추가) 타겟을 선택합니다.

5e6ad6dbf468585f.png

그런 다음 Xcode를 종료하고 선택한 IDE에서 Info.plist에 다음을 추가합니다.

ios/Runner/Info.plist

<key>CFBundleURLTypes</key>
<array>
        <dict>
                <key>CFBundleTypeRole</key>
                <string>Editor</string>
                <key>CFBundleURLSchemes</key>
                <array>
                        <!-- TODO Replace this value: -->
                        <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
                        <string>com.googleusercontent.apps.TODO-REPLACE-ME</string>
                </array>
        </dict>
</array>

생성된 GoogleService-Info.plist 파일의 항목과 일치하도록 값을 수정해야 합니다. 또한, 최소 iOS 버전도 9로 설정해야 합니다. 다음과 같이 ios/Podfile을 수정합니다.

ios/Podfile

# iOS 9 for google_sign_in
platform :ios, '9.0'      # Uncomment this line

# 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)
  end
end

앱을 실행하고 로그인하면 재생목록이 표시됩니다.

fe0d11203497c860.png

웹용 google_sign_in 구성

API 프로젝트의 사용자 인증 정보 페이지로 돌아가서 다른 OAuth 클라이언트 ID를 만들고 이번에는 웹 애플리케이션을 선택합니다.

7f745c53956c1572.png

양식의 나머지 부분의 경우 다음과 같이 승인된 JavaScript 출처에 채웁니다.

d45fb0e23e874e34.png

이렇게 하면 클라이언트 ID가 생성됩니다. web/index.html에 다음 meta 태그를 추가하여 생성된 클라이언트 ID를 포함하도록 업데이트합니다.

web/index.html

<meta name="google-signin-client_id" content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com">

이 샘플을 실행하려면 약간의 도움이 필요합니다. 이전 단계에서 만든 CORS 프록시를 실행해야 하고 다음 안내에 따라 웹 애플리케이션 OAuth 클라이언트 ID 양식에 지정된 포트에서 Flutter 웹 앱을 실행해야 합니다.

한 터미널에서는 다음과 같이 CORS 프록시 서버를 실행합니다.

$ dart run bin/server.dart
Server listening on port 8080

다른 터미널에서는 다음과 같이 Flutter 앱을 실행합니다.

$ flutter run -d chrome --web-hostname localhost --web-port 8090
Launching lib/main.dart on Chrome in debug mode...
Waiting for connection from debug service on Chrome...             20.4s
This app is linked to the debug service: ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws
Debug service listening on ws://127.0.0.1:52430/Nb3Q7puZqvI=/ws

💪 Running with sound null safety 💪

🔥  To hot restart changes while running, press "r" or "R".
For a more detailed help message, press "h". To quit, press "q".

한 번 더 로그인하면 재생목록이 표시됩니다.

c9c43252341fa197.png

8. 다음 단계

축하합니다.

Codelab을 완료하고 Flutter가 지원하는 6개의 플랫폼에서 모두 실행되는 적응형 Flutter 앱을 빌드했습니다. 코드를 조정하여 화면에 배치되는 방식, 텍스트가 상호작용하는 방식, 이미지가 로드되는 방식, 인증 작동 방식의 차이점을 해결했습니다.

애플리케이션에서 조정할 수 있는 항목은 더 많이 있습니다. 애플리케이션이 실행되는 다양한 환경에 맞게 코드를 조정하는 방법을 추가로 알아보려면 적응형 앱 빌드를 참고하세요.