1. Wprowadzenie
Flutter to zestaw narzędzi interfejsu Google do tworzenia atrakcyjnych, natywnie kompilowanych aplikacji na urządzenia mobilne, internet i komputery z pojedynczej bazy kodu. W tym module dowiesz się, jak utworzyć aplikację we Flutterze, która dostosowuje się do platformy, na której jest uruchomiona, czyli Androida, iOS, internetu, Windowsa, macOS lub Linuksa.
Czego się nauczysz
- Jak rozwinąć aplikację Flutter zaprojektowaną na urządzenia mobilne, aby działała na wszystkich 6 platformach obsługiwanych przez Fluttera.
- Różne interfejsy API Fluttera do wykrywania platformy i informacje o tym, kiedy należy używać poszczególnych interfejsów API.
- dostosowywanie się do ograniczeń i oczekiwań związanych z działaniem aplikacji w internecie;
- Jak używać różnych pakietów razem, aby obsługiwać pełen zakres platform Fluttera.
Co utworzysz
W tym module początkowo utworzysz aplikację Flutter na Androida i iOS, która będzie zawierać playlisty YouTube dotyczące Fluttera. Następnie dostosujesz tę aplikację do działania na 3 platformach komputerowych (Windows, macOS i Linux), modyfikując sposób wyświetlania informacji w zależności od rozmiaru okna aplikacji. Następnie dostosuj aplikację do internetu, umożliwiając zaznaczanie tekstu wyświetlanego w aplikacji, tak jak oczekują tego użytkownicy internetu. Na koniec dodasz do aplikacji uwierzytelnianie, aby móc przeglądać własne playlisty, a nie te utworzone przez zespół Fluttera. Wymaga to różnych podejść do uwierzytelniania w przypadku Androida, iOS i internetu w porównaniu z 3 platformami na komputery: Windows, macOS i Linux.
Oto zrzut ekranu aplikacji Flutter na Androidzie i iOS:
Aplikacja uruchomiona w trybie panoramicznym w systemie macOS powinna wyglądać jak na zrzucie ekranu poniżej.
W tym laboratorium skupimy się na przekształceniu mobilnej aplikacji Flutter w aplikację adaptacyjną, która działa na wszystkich 6 platformach Flutter. Nieistotne koncepcje i bloki kodu zostały zamaskowane. Można je po prostu skopiować i wkleić.
Czego chcesz się nauczyć podczas tego laboratorium?
2. Konfigurowanie środowiska programistycznego Fluttera
Do ukończenia tego modułu potrzebne są 2 programy: pakiet Flutter SDK i edytor.
Codelab możesz uruchomić na dowolnym z tych urządzeń:
- fizyczne urządzenie z Android lub iOS podłączone do komputera i ustawione w trybie deweloperskim;
- Symulator iOS (wymaga zainstalowania narzędzi Xcode).
- Emulator Androida (wymaga konfiguracji w Android Studio).
- przeglądarka (do debugowania wymagana jest Chrome);
- Jako aplikacja na komputery z systemem Windows, Linux lub macOS. Musisz tworzyć aplikację na platformie, na której zamierzasz ją wdrożyć. Jeśli chcesz opracować aplikację na komputery z systemem Windows, musisz to zrobić na komputerze z tym systemem, aby mieć dostęp do odpowiedniego łańcucha kompilacji. Istnieją wymagania dotyczące konkretnych systemów operacyjnych, które są szczegółowo opisane na stronie docs.flutter.dev/desktop.
3. Rozpocznij
Potwierdzanie środowiska programistycznego
Aby mieć pewność, że wszystko jest gotowe do programowania, uruchom to polecenie:
flutter doctor
Jeśli coś jest wyświetlane bez znacznika wyboru, uruchom to polecenie, aby uzyskać więcej informacji o tym, co jest nie tak:
flutter doctor -v
Może być konieczne zainstalowanie narzędzi dla programistów do tworzenia aplikacji mobilnych lub na komputery. Więcej informacji o konfigurowaniu narzędzi w zależności od systemu operacyjnego hosta znajdziesz w dokumentacji instalacji Flattera.
Tworzenie projektu Flutter
Aby zacząć pisać aplikacje na komputery w Flutterze, użyj narzędzia wiersza poleceń Fluttera do utworzenia projektu Fluttera. Możesz też utworzyć projekt Fluttera w interfejsie IDE.
$ flutter create adaptive_app Creating project adaptive_app... Resolving dependencies in adaptive_app... (1.8s) Got dependencies in adaptive_app. Wrote 129 files. All done! You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev In order to run your application, type: $ cd adaptive_app $ flutter run Your application code is in adaptive_app/lib/main.dart.
Aby upewnić się, że wszystko działa, uruchom przykładową aplikację Flutter jako aplikację mobilną, jak pokazano poniżej. Możesz też otworzyć ten projekt w IDE i uruchomić aplikację za pomocą narzędzi. Dzięki poprzedniemu krokowi uruchomienie jako aplikacja na komputer powinno być jedyną dostępną opcją.
$ flutter run Launching lib/main.dart on iPhone 15 in debug mode... Running Xcode build... └─Compiling, linking and signing... 6.5s Xcode build done. 24.6s Syncing files to device iPhone 15... 46ms 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). A Dart VM Service on iPhone 15 is available at: http://127.0.0.1:50501/JHGBwC_hFJo=/ The Flutter DevTools debugger and profiler on iPhone 15 is available at: http://127.0.0.1:9102?uri=http://127.0.0.1:50501/JHGBwC_hFJo=/
Aplikacja powinna być teraz uruchomiona. Treści wymagają aktualizacji.
Aby zaktualizować treść, zmień kod w pliku lib/main.dart
na ten poniżej. Aby zmienić to, co wyświetla aplikacja, wykonaj szybkie ponowne załadowanie.
- Jeśli uruchamiasz aplikację za pomocą wiersza poleceń, wpisz w konsoli
r
, aby włączyć szybkie przeładowanie. - Jeśli uruchomisz aplikację za pomocą IDE, po zapisaniu pliku aplikacja zostanie ponownie załadowana.
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(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
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.headlineSmall,
),
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';
}
}
}
Aplikacja ma na celu pokazanie, jak można wykrywać różne platformy i dostosowywać się do nich. Oto aplikacja działająca natywnie na Androidzie i iOS:
A oto ten sam kod uruchomiony natywnie w systemie macOS i w przeglądarce Chrome, również w systemie macOS.
Warto zauważyć, że na pierwszy rzut oka Flutter robi wszystko, co w jego mocy, aby dostosować treść do wyświetlacza, na którym jest uruchomiony. Laptop, na którym zrobiono te zrzuty ekranu, ma wyświetlacz Mac o wysokiej rozdzielczości, dlatego zarówno wersja aplikacji na macOS, jak i wersja internetowa są renderowane przy współczynniku pikseli urządzenia wynoszącym 2. W przypadku iPhone’a 12 współczynnik wynosi 3, a w przypadku Pixela 2 – 2,63. W każdym przypadku wyświetlany tekst jest mniej więcej podobny, co znacznie ułatwia nam pracę jako programistom.
Druga ważna kwestia to fakt, że 2 opcje sprawdzania, na której platformie działa kod, dają różne wartości. Pierwsza opcja sprawdza obiekt Platform
zaimportowany z dart:io
, a druga opcja (dostępna tylko w metodzie build
widżetu) pobiera obiekt Theme
z argumentu BuildContext
.
Te dwie metody zwracają różne wyniki, ponieważ mają inne przeznaczenie. Obiekt Platform
importowany z dart:io
jest przeznaczony do podejmowania decyzji niezależnych od opcji renderowania. Dobrym przykładem jest wybór wtyczek, które mogą, ale nie muszą być zgodne z natywnymi implementacjami na konkretnej platformie fizycznej.
Wyodrębnianie Theme
z BuildContext
jest przeznaczone do podejmowania decyzji dotyczących wdrożenia, które są związane z motywem. Dobrym przykładem jest decyzja, czy użyć suwaka Material czy suwaka Cupertino, o czym piszemy w sekcji Slider.adaptive
.
W następnej sekcji utworzysz podstawową aplikację do przeglądania playlist YouTube, która jest zoptymalizowana pod kątem Androida i iOS. W kolejnych sekcjach dodasz różne dostosowania, aby aplikacja działała lepiej na komputerach i w internecie.
4. Tworzenie aplikacji mobilnej
Dodawanie pakietów
W tej aplikacji użyjesz różnych pakietów Fluttera, aby uzyskać dostęp do YouTube Data API, zarządzania stanem i motywów.
$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router Resolving dependencies... Downloading packages... + _discoveryapis_commons 1.0.7 characters 1.4.0 (1.4.1 available) + flex_color_scheme 8.3.0 + flex_seed_scheme 3.5.1 > flutter_lints 6.0.0 (was 5.0.0) + flutter_web_plugins 0.0.0 from sdk flutter + go_router 16.2.0 + googleapis 14.0.0 + http 1.5.0 + http_parser 4.1.2 > lints 6.0.0 (was 5.1.1) + logging 1.3.0 material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.5 test_api 0.7.6 (0.7.7 available) + typed_data 1.4.0 + url_launcher 6.3.2 + url_launcher_android 6.3.17 + url_launcher_ios 6.3.4 + url_launcher_linux 3.2.1 + url_launcher_macos 3.2.3 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.4.1 + url_launcher_windows 3.1.4 + web 1.1.1 Changed 24 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
To polecenie dodaje do aplikacji kilka pakietów:
googleapis
: wygenerowana biblioteka Dart, która zapewnia dostęp do interfejsów API Google.http
: biblioteka do tworzenia żądań HTTP, która ukrywa różnice między przeglądarkami natywnymi a internetowymi.provider
: zapewnia zarządzanie stanem.url_launcher
: umożliwia przejście do filmu z playlisty. Jak widać na podstawie rozwiązanych zależności,url_launcher
ma implementacje dla systemów Windows, macOS, Linux i sieci, a także domyślne implementacje dla Androida i iOS. Korzystanie z tego pakietu oznacza, że nie musisz tworzyć platformy specyficznej dla tej funkcji.flex_color_scheme
: nadaje aplikacji ładną domyślną kolorystykę. Więcej informacji znajdziesz wflex_color_scheme
dokumentacji interfejsu API.go_router
: umożliwia nawigację między różnymi ekranami. Ten pakiet udostępnia wygodny interfejs API oparty na adresach URL do nawigacji za pomocą routera Fluttera.
Konfigurowanie aplikacji mobilnych dla usługi url_launcher
Wtyczka url_launcher
wymaga skonfigurowania aplikacji uruchamiających na Androida i iOS. W programie uruchamiającym Fluttera na iOS dodaj te wiersze do słownika plist
.
ios/Runner/Info.plist
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
<string>http</string>
<string>tel</string>
<string>mailto</string>
</array>
W programie uruchamiającym Fluttera na Androida dodaj te wiersze do sekcji Manifest.xml
. Dodaj ten węzeł queries
jako bezpośredni element podrzędny węzła manifest
i element równorzędny węzła 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>
Więcej informacji o tych wymaganych zmianach konfiguracji znajdziesz w dokumentacji url_launcher
.
Dostęp do YouTube Data API
Aby uzyskać dostęp do interfejsu YouTube Data API i wyświetlić listy odtwarzania, musisz utworzyć projekt API, aby wygenerować wymagane klucze interfejsu API. W tych krokach zakładamy, że masz już konto Google. Jeśli go nie masz, utwórz je.
Otwórz konsolę programisty, aby utworzyć projekt interfejsu API:
Gdy masz już projekt, otwórz stronę Biblioteka interfejsów API. W polu wyszukiwania wpisz „youtube” i wybierz youtube data api v3.
Na stronie szczegółów interfejsu YouTube Data API v3 włącz interfejs API.
Po włączeniu interfejsu API otwórz stronę Dane logowania i utwórz klucz interfejsu API.
Po kilku sekundach powinien pojawić się dialog z nowym kluczem interfejsu API. Ten klucz będzie potrzebny wkrótce.
Dodaj kod
W pozostałej części tego kroku będziesz wycinać i wklejać dużo kodu, aby utworzyć aplikację mobilną, bez żadnych komentarzy do kodu. Celem tych ćwiczeń jest dostosowanie aplikacji mobilnej do komputerów i internetu. Bardziej szczegółowe wprowadzenie do tworzenia aplikacji mobilnych w Flutterze znajdziesz w artykule Pierwsza aplikacja w Flutterze.
Dodaj te pliki, zaczynając od obiektu stanu aplikacji.
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();
nextPageToken = response.nextPageToken;
} 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));
}
}
Następnie dodaj stronę z informacjami o poszczególnych playlistach.
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).colorScheme.surface],
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.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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,
),
),
],
);
}
}
Następnie dodaj listę playlist.
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(),
);
},
),
);
},
);
}
}
Zastąp zawartość pliku main.dart
tym kodem:
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.uri.queryParameters['title']!;
final id = state.pathParameters['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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Już prawie wszystko gotowe, aby uruchomić ten kod na Androidzie i iOS. Musisz jeszcze tylko zmienić stałą youTubeApiKey
na klucz interfejsu API YouTube wygenerowany w poprzednim kroku.
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
Aby uruchomić tę aplikację w systemie macOS, musisz zezwolić jej na wysyłanie żądań HTTP w ten sposób: Zmodyfikuj pliki DebugProfile.entitlements
i Release.entitilements
w ten sposób:
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>
Uruchamianie aplikacji
Teraz, gdy masz już kompletną aplikację, powinna ona działać prawidłowo na emulatorze Androida lub symulatorze iPhone’a. Zobaczysz listę playlist Fluttera. Po wybraniu playlisty zobaczysz filmy na niej. Jeśli klikniesz przycisk odtwarzania, otworzy się YouTube, gdzie możesz obejrzeć film.
Jeśli jednak spróbujesz uruchomić tę aplikację na komputerze, zobaczysz, że układ jest nieprawidłowy po rozwinięciu do normalnego okna o rozmiarze komputera. W następnym kroku dowiesz się, jak się do tego dostosować.
5. Dostosowanie do komputera
Problem z komputerem
Jeśli uruchomisz aplikację na jednej z natywnych platform desktopowych, czyli Windows, macOS lub Linux, zauważysz ciekawy problem. Działa, ale wygląda… dziwnie.
Aby to naprawić, dodaj widok podzielony, w którym playlisty będą wyświetlane po lewej stronie, a filmy po prawej. Chcesz jednak, aby ten układ był używany tylko wtedy, gdy kod nie jest uruchomiony na Androidzie lub iOS, a okno jest wystarczająco szerokie. Poniższe instrukcje pokazują, jak wdrożyć tę funkcję.
Najpierw dodaj pakiet split_view
, aby ułatwić tworzenie układu.
$ flutter pub add split_view Resolving dependencies... Downloading packages... characters 1.4.0 (1.4.1 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + split_view 3.2.1 test_api 0.7.6 (0.7.7 available) Changed 1 dependency! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Wprowadzenie widżetów adaptacyjnych
W tym laboratorium kodu użyjesz wzorca, który wprowadza widżety adaptacyjne, które podejmują decyzje dotyczące implementacji na podstawie atrybutów takich jak szerokość ekranu czy motyw platformy. W tym przypadku wprowadzisz AdaptivePlaylists
widżet, który zmienia sposób interakcji Playlists
i PlaylistDetails
. Zmodyfikuj plik lib/main.dart
w ten sposób:
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'; // Add this import
import 'src/app_state.dart';
import 'src/playlist_details.dart';
// Remove the src/playlists.dart import
// 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(); // Modify this line
},
routes: <RouteBase>[
GoRoute(
path: 'playlist/:id',
builder: (context, state) {
final title = state.uri.queryParameters['title']!;
final id = state.pathParameters['id']!;
return Scaffold( // Modify from here
appBar: AppBar(title: Text(title)),
body: PlaylistDetails(playlistId: id, playlistName: title),
); // To here.
},
),
],
),
],
);
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).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System if you'd prefer
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Następnie utwórz plik widżetu 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: switch (selectedPlaylist?.snippet?.title) {
String title => Text('FlutterDev Playlist: $title'),
_ => const Text('FlutterDev Playlists'),
},
),
body: SplitView(
viewMode: SplitViewMode.Horizontal,
children: [
Playlists(
playlistSelected: (playlist) {
setState(() {
selectedPlaylist = playlist;
});
},
),
switch ((selectedPlaylist?.id, selectedPlaylist?.snippet?.title)) {
(String id, String title) => PlaylistDetails(
playlistId: id,
playlistName: title,
),
_ => const Center(child: Text('Select a playlist')),
},
],
),
);
}
}
Ten plik jest interesujący z kilku powodów. Po pierwsze, używa szerokości okna (za pomocą MediaQuery.of(context).size.width
) i sprawdza motyw (za pomocą Theme.of(context).platform
), aby zdecydować, czy wyświetlić szeroki układ z widżetem SplitView
, czy wąski układ bez niego.
Po drugie, ta sekcja dotyczy zakodowanej na stałe obsługi nawigacji. Wyświetla argument wywołania zwrotnego w widżecie Playlists
. Ta funkcja zwrotna powiadamia otaczający kod, że użytkownik wybrał playlistę. Kod musi następnie wykonać działanie, aby wyświetlić tę playlistę. Zmienia to potrzebę używania Scaffold
w widżetach Playlists
i PlaylistDetails
. Ponieważ nie są już najwyższego poziomu, musisz usunąć z nich symbol Scaffold
.
Następnie zmień plik src/lib/playlists.dart
, aby pasował do tego kodu:
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);
},
),
);
},
);
}
}
W tym pliku jest dużo zmian. Oprócz wspomnianego wyżej wprowadzenia wywołania zwrotnego playlistSelected
i usunięcia widżetu Scaffold
widżet _PlaylistsListView
został przekształcony z bezstanowego w stanowy. Ta zmiana jest wymagana ze względu na wprowadzenie własnego ScrollController
, który musi zostać utworzony i zniszczony.
Wprowadzenie ScrollController
jest interesujące, ponieważ jest wymagane, ponieważ w przypadku szerokiego układu masz 2 widżety ListView
obok siebie. Na telefonie komórkowym zwykle jest tylko jeden ListView
, więc może być tylko jeden długotrwały ListView
, do którego wszystkie ListView
są dołączane i odłączane w trakcie ich indywidualnych cykli życia.ScrollController
Na komputerach jest inaczej, ponieważ w tym przypadku wiele ListView
obok siebie ma sens.
Na koniec zmodyfikuj plik lib/src/playlist_details.dart
w ten sposób:
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).colorScheme.surface],
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.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
Text(
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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,
),
),
],
);
}
}
Podobnie jak w przypadku widżetu Playlists
powyżej, w tym pliku również wprowadzono zmiany polegające na wyeliminowaniu widżetu Scaffold
i wprowadzeniu własnego widżetu ScrollController
.
Uruchom aplikację ponownie.
Uruchamianie aplikacji na wybranym komputerze z systemem Windows, macOS lub Linux. Powinno teraz działać zgodnie z oczekiwaniami.
6. Dostosowywanie do internetu
O co chodzi z tymi obrazami?
Próba uruchomienia tej aplikacji w internecie pokazuje, że wymaga ona więcej pracy, aby dostosować ją do przeglądarek.
Jeśli zajrzysz do konsoli debugowania, zobaczysz delikatną wskazówkę, co musisz zrobić dalej.
══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════ The following ProgressEvent$ object was thrown resolving an image codec: [object ProgressEvent] When the exception was thrown, this was the stack Image provider: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0) Image key: NetworkImage("https://i.ytimg.com/vi/4AoFA19gbLo/default.jpg", scale: 1.0) ════════════════════════════════════════════════════════════════════════════════════════════════════
Tworzenie serwera proxy CORS
Jednym ze sposobów rozwiązania problemów z renderowaniem obrazów jest wprowadzenie internetowej usługi proxy, która dodaje wymagane nagłówki mechanizmu CORS. Otwórz terminal i utwórz serwer WWW Dart w ten sposób:
$ 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
Zmień katalog na serwer yt_cors_proxy
i dodaj kilka wymaganych zależności:
$ cd yt_cors_proxy $ dart pub add shelf_cors_headers http "http" was found in dev_dependencies. Removing "http" and adding it to dependencies instead. Resolving dependencies... Downloading packages... http 1.5.0 (from dev dependency to direct dependency) + shelf_cors_headers 0.1.5 Changed 2 dependencies!
Istnieje bieżąca zależność, która nie jest już wymagana. Przytnij go w ten sposób:
$ dart pub remove shelf_router Resolving dependencies... Downloading packages... These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 Changed 2 dependencies!
Następnie zmień zawartość pliku server.dart, aby pasowała do poniższej:
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}');
}
Serwer możesz uruchomić w ten sposób:
$ dart run bin/server.dart Server listening on port 8080
Możesz też skompilować go jako obraz Dockera i uruchomić go w ten sposób:
$ 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
Następnie zmodyfikuj kod Fluttera, aby korzystać z tego serwera proxy CORS, ale tylko wtedy, gdy jest on uruchamiany w przeglądarce.
Para widżetów, które można dostosowywać
Pierwszy z nich pokazuje, jak aplikacja będzie korzystać z serwera proxy 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);
}
}
Ta aplikacja używa kIsWeb
stałej ze względu na różnice w platformie wykonawczej. Drugi widżet adaptacyjny zmienia aplikację tak, aby działała jak inne strony internetowe. Użytkownicy przeglądarek oczekują, że tekst będzie można zaznaczyć.
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) {
return switch (Theme.of(context).platform) {
TargetPlatform.android || TargetPlatform.iOS => Text(data, style: style),
_ => SelectableText(data, style: style),
};
}
}
Teraz rozpowszechnij te zmiany w całym kodzie:
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).colorScheme.surface],
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( // Also, this line
playlistItem.snippet!.title!,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
fontSize: 18,
// fontWeight: FontWeight.bold,
),
),
if (playlistItem.snippet!.videoOwnerChannelTitle != null)
AdaptiveText( // And this line
playlistItem.snippet!.videoOwnerChannelTitle!,
style: Theme.of(
context,
).textTheme.bodyMedium!.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,
),
),
],
);
}
}
W powyższym kodzie dostosowano widżety Image.network
i Text
. Następnie dostosuj widżet 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);
},
),
);
},
);
}
}
Tym razem dostosowano tylko widżet Image.network
, a pozostałe 2 widżety Text
pozostawiono bez zmian. Było to zamierzone, ponieważ jeśli dostosujesz widżety tekstowe, funkcja onTap
ListTile
jest blokowana, gdy użytkownik kliknie tekst.
Uruchamianie aplikacji w internecie
Po uruchomieniu serwera proxy CORS możesz uruchomić wersję internetową aplikacji. Powinna ona wyglądać mniej więcej tak:
7. Uwierzytelnianie adaptacyjne
W tym kroku rozszerzysz aplikację, dodając do niej możliwość uwierzytelniania użytkownika, a następnie wyświetlania jego playlist. Aby obsługiwać różne platformy, na których może działać aplikacja, musisz używać wielu wtyczek, ponieważ obsługa protokołu OAuth wygląda zupełnie inaczej w przypadku Androida, iOS, internetu, Windowsa, macOS i Linuksa.
Dodawanie wtyczek w celu włączenia uwierzytelniania w Google
Zainstalujesz 3 pakiety do obsługi uwierzytelniania Google.
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth logging Resolving dependencies... Downloading packages... + args 2.7.0 characters 1.4.0 (1.4.1 available) + crypto 3.0.6 + extension_google_sign_in_as_googleapis_auth 3.0.0 + google_identity_services_web 0.3.3+1 + google_sign_in 7.1.1 + google_sign_in_android 7.0.3 + google_sign_in_ios 6.1.0 + google_sign_in_platform_interface 3.0.0 + google_sign_in_web 1.0.0 + googleapis_auth 2.0.0 logging 1.3.0 (from transitive dependency to direct dependency) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) test_api 0.7.6 (0.7.7 available) Changed 11 dependencies! 4 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Aby uwierzytelnić się w systemach Windows, macOS i Linux, użyj pakietu googleapis_auth
. Te platformy na komputery uwierzytelniają się za pomocą przeglądarki. Do uwierzytelniania na Androidzie, iOS i w internecie używaj pakietów google_sign_in
i extension_google_sign_in_as_googleapis_auth
. Drugi pakiet działa jako warstwa pośrednia między tymi dwoma pakietami.
Aktualizowanie kodu
Zacznij aktualizację od utworzenia nowej abstrakcji wielokrotnego użytku, widżetu AdaptiveLogin. Ten widżet jest przeznaczony do ponownego użycia, dlatego wymaga pewnej konfiguracji:
lib/src/adaptive_login.dart
import 'dart:async';
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:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/link.dart';
import 'app_state.dart';
final _log = Logger('AdaptiveLogin');
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.instance;
_googleSignIn.initialize();
_authEventsSubscription = _googleSignIn.authenticationEvents.listen((
event,
) async {
_log.fine('Google Sign-In authentication event: $event');
if (event is GoogleSignInAuthenticationEventSignIn) {
final googleSignInClientAuthorization = await event
.user
.authorizationClient
.authorizationForScopes(widget.scopes);
if (googleSignInClientAuthorization == null) {
_log.warning('Google Sign-In authenticated client creation failed');
return;
}
_log.fine('Google Sign-In authenticated client created');
final context = this.context;
if (context.mounted) {
context.read<AuthedUserPlaylists>().authClient =
googleSignInClientAuthorization.authClient(scopes: widget.scopes);
context.go('/');
}
}
});
// Check if user is already authenticated
_log.fine('Attempting lightweight authentication');
_googleSignIn.attemptLightweightAuthentication();
}
@override
dispose() {
_authEventsSubscription.cancel();
super.dispose();
}
late final GoogleSignIn _googleSignIn;
late final StreamSubscription _authEventsSubscription;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: widget.button(
onPressed: () {
_googleSignIn.authenticate();
},
),
),
);
}
}
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) {
final context = this.context;
if (context.mounted) {
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()));
}
}
Ten plik ma wiele funkcji. Metoda AdaptiveLogin
build
wykonuje za Ciebie większość pracy. Wywołując zarówno kIsWeb
, jak i dart:io
, ta metoda sprawdza platformę środowiska wykonawczego.Platform.isXXX
W przypadku Androida, iOS i internetu tworzy instancję widżetu z zachowywaniem stanu _GoogleSignInLogin
. W przypadku systemów Windows, macOS i Linux tworzy widżet stanowy _GoogleApisAuthLogin
.
Aby używać tych klas, musisz przeprowadzić dodatkową konfigurację, którą omówimy później, po zaktualizowaniu pozostałej części bazy kodu w celu używania tego nowego widżetu. Zacznij od zmiany nazwy FlutterDevPlaylists
na AuthedUserPlaylists
, aby lepiej odzwierciedlić jego nowe przeznaczenie, i zaktualizuj kod, aby odzwierciedlić fakt, że http.Client
jest teraz przekazywany po utworzeniu. Klasa _ApiKeyClient
nie jest już wymagana:
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();
nextPageToken = response.nextPageToken;
} while (nextPageToken != null);
}
YouTubeApi? _api; // Convert 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
Następnie zaktualizuj widżet PlaylistDetails
, podając nową nazwę obiektu stanu aplikacji:
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, playlists, _) {
final playlistItems = playlists.playlistItems(playlistId: playlistId);
if (playlistItems.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return _PlaylistDetailsListView(playlistItems: playlistItems);
},
);
}
}
Podobnie zaktualizuj widżet Playlists
:
lib/src/playlists.dart
class Playlists extends StatelessWidget {
const Playlists({super.key, required this.playlistSelected});
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,
);
},
);
}
}
Na koniec zaktualizuj plik main.dart
, aby prawidłowo używać nowego widżetu 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.uri.queryParameters['title']!;
final id = state.pathParameters['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.router(
title: 'Your Playlists', // Change FlutterDev to Your
theme: FlexColorScheme.light(scheme: FlexScheme.red).toTheme,
darkTheme: FlexColorScheme.dark(scheme: FlexScheme.red).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Zmiany w tym pliku odzwierciedlają przejście od wyświetlania tylko playlist YouTube Fluttera do wyświetlania playlist uwierzytelnionego użytkownika. Kod jest już gotowy, ale w tym pliku i w plikach w odpowiednich aplikacjach Runner trzeba jeszcze wprowadzić szereg zmian, aby prawidłowo skonfigurować pakiety google_sign_in
i googleapis_auth
na potrzeby uwierzytelniania.
Aplikacja wyświetla teraz playlisty w YouTube uwierzytelnionego użytkownika. Po ukończeniu funkcji musisz włączyć uwierzytelnianie. Aby to zrobić, skonfiguruj pakiety google_sign_in
i googleapis_auth
. Aby skonfigurować pakiety, musisz zmienić plik main.dart
i pliki aplikacji Runner.
Skonfiguruj googleapis_auth
Pierwszym krokiem w konfigurowaniu uwierzytelniania jest usunięcie wcześniej skonfigurowanego i używanego klucza interfejsu API. Otwórz stronę danych logowania projektu interfejsu API i usuń klucz interfejsu API:
Spowoduje to wyświetlenie okna dialogowego, które możesz potwierdzić, klikając przycisk Usuń:
Następnie utwórz identyfikator klienta OAuth:
Jako typ aplikacji wybierz Aplikacja na komputer.
Zaakceptuj nazwę i kliknij Utwórz.
Spowoduje to utworzenie identyfikatora klienta i tajnego klucza klienta, które musisz dodać do lib/main.dart
, aby skonfigurować przepływ googleapis_auth
. Ważnym szczegółem implementacji jest to, że przepływ googleapis_auth używa tymczasowego serwera internetowego działającego na hoście lokalnym do przechwytywania wygenerowanego tokena OAuth, co w systemie macOS wymaga modyfikacji pliku 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>
Nie musisz wprowadzać tej zmiany w pliku macos/Runner/DebugProfile.entitlements
, ponieważ ma on już uprawnienia do com.apple.security.network.server
, które umożliwiają szybkie przeładowanie i narzędzia do debugowania maszyny wirtualnej Dart.
Teraz możesz uruchomić aplikację w systemie Windows, macOS lub Linux (jeśli została skompilowana na tych platformach).
Konfigurowanie google_sign_in
na Androidzie
Wróć na stronę danych logowania projektu interfejsu API i utwórz kolejny identyfikator klienta OAuth, ale tym razem wybierz Android:
W pozostałych polach formularza wpisz nazwę pakietu zadeklarowaną w android/app/src/main/AndroidManifest.xml
. Jeśli postępujesz zgodnie z instrukcjami, powinna być to wartość com.example.adaptive_app
. Wyodrębnij odcisk cyfrowy certyfikatu SHA-1, postępując zgodnie z instrukcjami na stronie pomocy konsoli Google Cloud:
Wystarczy to, aby aplikacja działała na Androidzie. W zależności od tego, których interfejsów API Google używasz, może być konieczne dodanie wygenerowanego pliku JSON do pakietu aplikacji.
Konfigurowanie google_sign_in
na urządzeniach z iOS
Wróć na stronę danych logowania projektu interfejsu API i utwórz kolejny identyfikator klienta OAuth, ale tym razem wybierz iOS:
W pozostałych polach formularza wpisz identyfikator pakietu, otwierając ios/Runner.xcworkspace
w Xcode. Otwórz nawigator projektu, wybierz w nim Runner, a następnie kliknij kartę General i skopiuj identyfikator pakietu. Jeśli wykonasz wszystkie kroki tego ćwiczenia, powinna to być wartość com.example.adaptiveApp
.
W pozostałych polach formularza wpisz identyfikator pakietu. Otwórz plik ios/Runner.xcworkspace
w Xcode. Otwórz nawigator projektów. Otwórz kartę Runner > Ogólne. Skopiuj identyfikator pakietu. Jeśli wykonasz wszystkie kroki tego samouczka, wartość powinna wynosić com.example.adaptiveApp
.
Na razie zignoruj identyfikator App Store i identyfikator zespołu, ponieważ nie są one wymagane w przypadku lokalnego tworzenia aplikacji:
Pobierz wygenerowany plik .plist
. Jego nazwa jest oparta na wygenerowanym identyfikatorze klienta. Zmień nazwę pobranego pliku na GoogleService-Info.plist
, a następnie przeciągnij go do uruchomionego edytora Xcode obok pliku Info.plist
w sekcji Runner/Runner
w nawigatorze po lewej stronie. W oknie opcji w Xcode wybierz w razie potrzeby Copy items (Kopiuj elementy), Create folder references (Utwórz odwołania do folderów) i Add to the Runner (Dodaj do elementu docelowego Runner).
Zamknij Xcode, a potem w wybranym środowisku IDE dodaj do pliku Info.plist
ten kod:
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>
Musisz edytować wartość, aby była zgodna z wpisem w wygenerowanym pliku GoogleService-Info.plist
. Uruchom aplikację i po zalogowaniu się powinny pojawić się Twoje playlisty.
Konfigurowanie google_sign_in
w witrynie
Wróć na stronę danych logowania projektu interfejsu API i utwórz kolejny identyfikator klienta OAuth, ale tym razem wybierz Aplikacja internetowa:
W pozostałej części formularza wypełnij pole Autoryzowane źródła JavaScriptu w ten sposób:
Spowoduje to wygenerowanie identyfikatora klienta. Dodaj ten tag meta
do web/index.html
, zaktualizowany o wygenerowany identyfikator klienta:
web/index.html
<meta
name="google-signin-client_id"
content="YOUR_GOOGLE_SIGN_IN_OAUTH_CLIENT_ID.apps.googleusercontent.com"
/>
Uruchomienie tego przykładu wymaga pewnej pomocy. Musisz uruchomić serwer proxy CORS utworzony w poprzednim kroku oraz aplikację internetową Flutter na porcie określonym w formularzu identyfikatora klienta OAuth aplikacji internetowej, postępując zgodnie z tymi instrukcjami.
W jednym terminalu uruchom serwer proxy CORS w ten sposób:
$ dart run bin/server.dart Server listening on port 8080
W innym terminalu uruchom aplikację Flutter w ten sposób:
$ 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".
Po ponownym zalogowaniu powinny pojawić się Twoje playlisty:
8. Dalsze kroki
Gratulacje!
Ukończysz codelab i utworzysz adaptacyjną aplikację Fluttera, która działa na wszystkich 6 platformach obsługiwanych przez Fluttera. Dostosowaliśmy kod, aby uwzględniał różnice w układzie ekranów, sposobie interakcji z tekstem, ładowaniu obrazów i działaniu uwierzytelniania.
W aplikacjach możesz dostosować wiele innych elementów. Więcej informacji o innych sposobach dostosowywania kodu do różnych środowisk, w których będzie on działać, znajdziesz w artykule Tworzenie aplikacji adaptacyjnych.