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에서 실행되는 앱의 스크린샷입니다.
이 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!
첫 번째 패키지 googleapis
는 Google 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 프로젝트를 만듭니다.
프로젝트를 만든 후에는 API 라이브러리 페이지로 이동합니다. 검색창에 'youtube'를 입력하고 youtube data api v3을 선택합니다.
YouTube Data API v3 세부정보 페이지에서 API를 사용 설정합니다.
API를 사용 설정한 후에는 사용자 인증 정보 페이지로 이동하여 API 키를 만듭니다.
몇 초 뒤 새로운 API 키가 대화상자에 표시됩니다. 이 키는 곧바로 사용할 예정입니다.
코드 추가
이 단계의 나머지 부분에서는 코드에 대한 설명 없이 많은 코드를 잘라서 붙여넣는 방식으로 모바일 앱을 빌드합니다. 이 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.entitlements
와 Release.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에서 사용하려는 방법은 화면 너비, 플랫폼 테마 등과 같은 속성에 따라 구현을 선택하는 적응형 위젯을 도입하는 것입니다. 이 경우에는 Playlists
와 PlaylistDetails
가 상호작용하는 방식을 다시 작성하는 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
위젯의 콜백 인수를 표시하여 처리됩니다. 또한, 이제 Scaffold
는 Playlists
와 PlaylistDetails
위젯에서 제외되었으며 이러한 위젯은 최상위 수준에 있지 않습니다.
다음으로, 아래와 같이 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 중 선택한 데스크톱에서 앱을 실행합니다. 예상한 대로 앱이 작동해야 합니다.
6. 웹에 맞게 조정
이미지에 무슨 일이 있는 것일까요?
이 앱을 현재 상태로 웹에서 실행하려고 하면 이전 단계에서 레이아웃을 변경했음에도 웹브라우저 환경에 맞추기 위한 작업이 남아있는 것을 볼 수 있습니다.
디버그 콘솔을 살펴보면 다음에 해야 할 작업에 관한 유용한 힌트를 얻을 수 있습니다.
════════ 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.network
와 Text
위젯을 모두 조정했습니다. 다음으로 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 위젯을 조정할 경우 사용자가 텍스트를 탭할 때 ListTile
의 onTap
기능이 차단되기 때문입니다.
웹에서 제대로 앱 실행
CORS 프록시가 실행 중일 때 앱의 웹 버전을 실행할 수 있어야 하고 다음과 같은 모습이어야 합니다.
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(),
),
);
}
}
이 파일에는 진행할 내용이 많습니다. 중요한 점은 AdaptiveLogin
의 build
메서드에서 kIsWeb
의 조합과 dart:io
의 Platform.isXXX
호출을 사용하여 런타임 플랫폼을 확인하며 Android, iOS, 웹의 경우에는 _GoogleSignInLogin
스테이트풀 위젯을 인스턴스화하고 Windows, macOS, Linux에서는 _GoogleApisAuthLogin
스테이트풀 위젯을 생성한다는 것입니다.
이 새로운 위젯을 사용하기 위해 나머지 코드베이스를 업데이트한 후 이러한 클래스를 사용하려면 이후에 나오는 추가 구성이 필요합니다. 수명 주기 동안 새로운 목적을 잘 반영하기 위해 FlutterDevPlaylists
를 AuthedUserPlaylists
로 이름을 바꾸고 코드를 업데이트하여 생성 후 이제 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_in
과 googleapis_auth
를 제대로 구성하기 위해 아직 이 파일과 각 실행기 앱 아래 파일에 필요한 일련의 수정사항이 남아있습니다.
googleapis_auth
구성
인증을 구성하는 첫 번째 단계는 이전에 구성하고 사용하던 API 키를 제거하는 것입니다. API 프로젝트의 사용자 인증 정보 페이지로 이동하여 API 키를 삭제합니다.
이렇게 하면 삭제 버튼을 눌러 확인하는 팝업이 생성됩니다.
그런 다음 OAuth 클라이언트 ID를 만듭니다.
애플리케이션 유형으로 데스크톱 앱을 선택합니다.
이름을 입력하고 만들기를 클릭합니다.
이렇게 하면 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에서 실행되어야 합니다(앱이 이러한 타겟에서 컴파일된 경우).
Android용 google_sign_in
구성
API 프로젝트의 사용자 인증 정보 페이지로 돌아가서 다른 OAuth 클라이언트 ID를 만들고 이번에는 Android를 선택합니다.
양식의 나머지 부분의 경우 android/app/src/main/AndroidManifest.xml
에 선언된 패키지로 패키지 이름을 채웁니다. 안내를 그대로 따랐다면 패키지 이름은 com.example.adaptive_app
이어야 합니다. Google Cloud Platform Console 도움말 페이지의 안내를 사용하여 SHA-1 인증서 지문을 추출합니다.
이 정도면 Android에서 작동하는 앱을 만들기에 충분합니다. 사용하는 Google API 선택에 따라 애플리케이션 번들에 생성된 JSON 파일을 추가해야 할 수도 있습니다.
iOS용 google_sign_in
구성
API 프로젝트의 사용자 인증 정보 페이지로 돌아가서 다른 OAuth 클라이언트 ID를 만들고 이번에는 iOS를 선택합니다.
.
양식의 나머지 부분의 경우 Xcode에서 ios/Runner.xcworkspace
를 열어 번들 ID를 채웁니다. 프로젝트 탐색기로 이동하고 탐색기에서 실행기를 선택한 다음 일반 탭을 선택하여 번들 식별자를 복사합니다. 이 Codelab을 단계별로 따라왔다면 이 값은 com.example.adaptiveApp
이어야 합니다.
지금으로서는 앱 스토어 ID와 팀 ID가 로컬 개발에 필요하지 않으므로 무시합니다.
생성된 클라이언트 ID를 기반으로 이름이 지정된 생성된 .plist
파일을 다운로드합니다. 다운로드한 파일의 이름을 GoogleService-Info.plist
로 바꾼 다음 탐색기 왼쪽 Runner/Runner
아래에 있는 Info.plist
파일과 함께 실행 중인 Xcode 편집기로 드래그합니다. Xcode의 옵션 대화상자에서 Copy items if needed(필요한 경우 항목 복사), Create folder references(폴더 참조 만들기), Add to the Runner(실행기에 추가) 타겟을 선택합니다.
그런 다음 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
앱을 실행하고 로그인하면 재생목록이 표시됩니다.
웹용 google_sign_in
구성
API 프로젝트의 사용자 인증 정보 페이지로 돌아가서 다른 OAuth 클라이언트 ID를 만들고 이번에는 웹 애플리케이션을 선택합니다.
양식의 나머지 부분의 경우 다음과 같이 승인된 JavaScript 출처에 채웁니다.
이렇게 하면 클라이언트 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".
한 번 더 로그인하면 재생목록이 표시됩니다.
8. 다음 단계
축하합니다.
Codelab을 완료하고 Flutter가 지원하는 6개의 플랫폼에서 모두 실행되는 적응형 Flutter 앱을 빌드했습니다. 코드를 조정하여 화면에 배치되는 방식, 텍스트가 상호작용하는 방식, 이미지가 로드되는 방식, 인증 작동 방식의 차이점을 해결했습니다.
애플리케이션에서 조정할 수 있는 항목은 더 많이 있습니다. 애플리케이션이 실행되는 다양한 환경에 맞게 코드를 조정하는 방법을 추가로 알아보려면 적응형 앱 빌드를 참고하세요.