1. Wprowadzenie
Flutter to opracowany przez Google zestaw narzędzi interfejsu do tworzenia pięknych, natywnie skompilowanych aplikacji na urządzenia mobilne, komputery i komputery przy użyciu jednej bazy kodu. Dzięki temu ćwiczeniu w Codelabs dowiesz się, jak stworzyć aplikację Flutter, która dostosowuje się do platformy, na której działa – czy jest to internet, iOS, Android, Windows, macOS czy Linux.
Czego się nauczysz
- Jak rozwinąć aplikację Flutter przeznaczoną na urządzenia mobilne, aby działała na wszystkich 6 platformach obsługiwanych przez Flutter.
- Różne interfejsy Flutter API do wykrywania platform i kiedy używać każdego z nich.
- Dostosowanie do ograniczeń i oczekiwań związanych z uruchamianiem aplikacji w internecie.
- Jak używać różnych pakietów obok siebie, aby obsługiwać pełny zakres platform Flutter.
Co utworzysz
W ramach tego ćwiczenia w programie utworzysz aplikację Flutter na Androida i iOS, która będzie przeglądać playlisty Flutter w YouTube. Dostosuj tę aplikację, aby działała na 3 platformach stacjonarnych (Windows, macOS i Linux) przez zmianę sposobu wyświetlania informacji w zależności od rozmiaru okna aplikacji. Następnie dostosujesz aplikację do internetu, ustawiając możliwość wyboru tekstu wyświetlanego w aplikacji zgodnie z oczekiwaniami użytkowników internetu. Na koniec musisz dodać do aplikacji funkcję uwierzytelniania, aby umożliwić Ci przeglądanie własnych playlist w odróżnieniu od tych utworzonych przez zespół Flutter, które wymagają innego podejścia do uwierzytelniania na urządzeniach z Androidem, iOS i w internecie niż na 3 platformach komputerowych (Windows, macOS i Linux).
Oto zrzut ekranu aplikacji Flutter na Androida i iOS:
Ta aplikacja działająca w trybie panoramicznym w systemie macOS powinna wyglądać mniej więcej tak, jak na zrzucie ekranu poniżej.
Skupia się on na przekształceniu aplikacji mobilnej 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ę dowiedzieć z tego ćwiczenia z programowania?
2. Konfigurowanie środowiska programistycznego Flutter
Aby ukończyć ten moduł, potrzebujesz 2 oprogramowania: pakietu SDK Flutter i edytora.
Ćwiczenie z programowania możesz uruchomić na dowolnym z tych urządzeń:
- Fizyczne urządzenie z Androidem lub iOS podłączone do komputera i ustawione w trybie programisty.
- Symulator iOS (wymaga zainstalowania narzędzi Xcode).
- Emulator Androida (wymaga skonfigurowania Android Studio).
- Przeglądarka (do debugowania wymagany jest Chrome).
- Aplikacja komputerowa w systemie Windows, Linux lub macOS Programowanie należy tworzyć na platformie, na której zamierzasz wdrożyć usługę. Jeśli więc chcesz opracować aplikację komputerową dla systemu Windows, musisz to zrobić w tym systemie, aby uzyskać dostęp do odpowiedniego łańcucha kompilacji. Istnieją wymagania związane z konkretnymi systemami operacyjnymi, które zostały szczegółowo omówione na stronie docs.flutter.dev/desktop.
3. Rozpocznij
Potwierdzanie środowiska programistycznego
Najłatwiejszym sposobem sprawdzenia, czy wszystko jest gotowe do programowania, jest uruchomienie tego polecenia:
$ flutter doctor
Jeśli cokolwiek wyświetla się bez znacznika wyboru, uruchom te polecenia, by dowiedzieć się więcej o błędach:
$ flutter doctor -v
Może być konieczne zainstalowanie narzędzi dla programistów do tworzenia aplikacji na urządzenia mobilne lub komputery. Więcej informacji o konfigurowaniu narzędzi w zależności od systemu operacyjnego hosta znajdziesz w dokumentacji instalacji Flutter.
Tworzenie projektu Flutter
Prostym sposobem na rozpoczęcie pisania aplikacji Flutter na komputery jest utworzenie projektu Flutter w narzędziu wiersza poleceń Flutter. W Twoim IDE może też być dostępny przepływ pracy do tworzenia projektu Flutter za pomocą jego interfejsu.
$ 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 sprawdzić, czy wszystko działa, uruchom aplikację Flutter jako aplikację mobilną, jak pokazano poniżej. Możesz też otworzyć ten projekt w swoim IDE i użyć jego narzędzi do uruchomienia aplikacji. Zgodnie z poprzednim krokiem uruchamianie aplikacji na komputerze 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. Trzeba zaktualizować treść.
Aby zaktualizować treść, zaktualizuj kod w sekcji lib/main.dart
za pomocą poniższego kodu. Aby zmienić to, co wyświetla się w aplikacji, odśwież stronę z pamięci.
- Jeśli uruchamiasz aplikację z poziomu wiersza poleceń, wpisz w konsoli
r
, aby odświeżyć ją z pamięci. - Jeśli uruchomisz aplikację przy użyciu IDE, po zapisaniu pliku zostanie ona załadowana ponownie.
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';
}
}
}
Powyższa aplikacja daje Ci poczucie, że można wykrywać i dostosowywać różne platformy. Oto aplikacja, która działa natywnie na Androida i iOS:
Ten sam kod działa natywnie w systemie macOS i w Chrome, a ponownie działa w systemie macOS.
Warto zauważyć, że na pierwszy rzut oka usługa Flutter robi wszystko, co w jej mocy, aby dostosować treści do wyświetlacza, na którym jest wyświetlana. Laptop, na którym wykonano zrzuty ekranu, ma wyświetlacz Mac o wysokiej rozdzielczości, dlatego zarówno wersja internetowa, jak i macOS, są renderowane ze współczynnikiem liczby pikseli urządzenia wynoszącym 2. Tymczasem na iPhonie 12 widoczny jest współczynnik proporcji 3 i 2,63 na Pixelu 2. We wszystkich przypadkach wyświetlany tekst jest podobny, co znacznie ułatwia pracę programistom.
Drugą istotną kwestią jest to, że 2 opcje sprawdzania, na której platformie działa kod, mają różne wartości. Pierwsza opcja sprawdza obiekt Platform
zaimportowany z metody dart:io
, a druga (dostępna tylko w metodzie build
widżetu) pobiera obiekt Theme
z argumentu BuildContext
.
Te 2 metody zwracają różne wyniki, ponieważ ich intencje są odmienne. Obiekt Platform
zaimportowany z narzędzia dart:io
służy do podejmowania decyzji niezależnych od ustawień renderowania. Świetnym przykładem jest tutaj wybór wtyczek, które mogą (ale nie muszą) mieć natywne implementacje pasujące do konkretnej platformy fizycznej.
Wyodrębnienie Theme
z BuildContext
umożliwia podejmowanie decyzji na podstawie motywu. Świetnym przykładem jest tutaj podjęcie decyzji o użyciu suwaka Materiał lub suwaka Cupertino, jak omówiono w sekcji Slider.adaptive
.
W następnej sekcji utworzysz podstawową aplikację Eksplorator playlist w YouTube zoptymalizowaną wyłącznie pod kątem Androida i iOS. W kolejnych sekcjach dodasz różne dostosowania, aby aplikacja działała lepiej na komputerach i w przeglądarce.
4. Tworzenie aplikacji mobilnej
Dodaj pakiety
W tej aplikacji będziesz korzystać z różnych pakietów Flutter, które pozwolą Ci uzyskać dostęp do interfejsu YouTube Data API, a także do zarządzania stanem i różnych motywów.
$ flutter pub add googleapis http provider url_launcher flex_color_scheme go_router Resolving dependencies... Downloading packages... + _discoveryapis_commons 1.0.6 + flex_color_scheme 7.3.1 + flex_seed_scheme 1.5.0 + flutter_web_plugins 0.0.0 from sdk flutter + go_router 14.0.1 + googleapis 13.1.0 + http 1.2.1 + http_parser 4.0.2 leak_tracker 10.0.4 (10.0.5 available) leak_tracker_flutter_testing 3.0.3 (3.0.5 available) + logging 1.2.0 material_color_utilities 0.8.0 (0.11.1 available) meta 1.12.0 (1.14.0 available) + nested 1.0.0 + plugin_platform_interface 2.1.8 + provider 6.1.2 test_api 0.7.0 (0.7.1 available) + typed_data 1.3.2 + url_launcher 6.2.6 + url_launcher_android 6.3.1 + url_launcher_ios 6.2.5 + url_launcher_linux 3.1.1 + url_launcher_macos 3.1.0 + url_launcher_platform_interface 2.3.2 + url_launcher_web 2.3.1 + url_launcher_windows 3.1.1 + web 0.5.1 Changed 22 dependencies! 5 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
To polecenie dodaje do aplikacji szereg 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 przeglądarkami.provider
: umożliwia zarządzanie stanem.url_launcher
: umożliwia przejście do filmu z playlisty. Z rozwiązanych zależności wynika, że oprócz domyślnych zależności dla Androida i iOSurl_launcher
ma implementacje w systemach Windows, macOS, Linux i w internecie. Dzięki temu pakietowi nie będzie trzeba tworzyć platformy dla tej funkcji.flex_color_scheme
: nadaje aplikacji ładny domyślny schemat kolorów. Więcej informacji znajdziesz w dokumentacji interfejsuflex_color_scheme
API.go_router
: stosuje nawigację między różnymi ekranami. Ten pakiet udostępnia wygodny interfejs API oparty na adresie URL do nawigacji za pomocą routera Flutter.
Konfigurowanie aplikacji mobilnych dla użytkownika url_launcher
Wtyczka url_launcher
wymaga skonfigurowania aplikacji uruchamiających na Androida i iOS. W narzędziu biegowym iOS Flutter dodaj następujące 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 narzędziu Android Flutter (Android) dodaj te wiersze do elementu Manifest.xml
. Dodaj ten węzeł queries
jako bezpośredni element podrzędny węzła manifest
i instancję równorzędną 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 wymaganych zmianach w konfiguracji znajdziesz w dokumentacji url_launcher
.
Dostęp do interfejsu YouTube Data API
Aby mieć dostęp do interfejsu YouTube Data API w celu wyświetlania playlist, musisz utworzyć projekt API, który wygeneruje wymagane klucze API. W poniższych instrukcjach przyjęliśmy, że masz już konto Google. Jeśli nie masz jeszcze konta, utwórz je.
Przejdź do Konsoli programisty, aby utworzyć projekt API:
Gdy masz projekt, otwórz stronę Biblioteka interfejsów API. W polu wyszukiwania wpisz „youtube” i wybierz youtube data api v3.
Włącz ten interfejs na stronie ze szczegółami interfejsu YouTube Data API v3.
Po włączeniu interfejsu API otwórz stronę Dane logowania i utwórz klucz interfejsu API.
Po kilku sekundach powinno się wyświetlić okno z nowym kluczem interfejsu API. Wkrótce będziesz go używać.
Dodaj kod
Pozostała część tego kroku wymaga wklejenia dużej ilości kodu i stworzenie aplikacji mobilnej bez komentarza. Ćwiczenie z programowania ma na celu dostosowanie aplikacji mobilnej zarówno na komputery, jak i na komputery. Bardziej szczegółowe informacje o tworzeniu aplikacji Flutter na urządzenia mobilne znajdziesz w artykułach Pisanie pierwszej aplikacji Flutter, część 1, część 2 oraz Tworzenie atrakcyjnych interfejsów użytkownika za pomocą Flutter.
Dodaj wymienione niżej pliki – najpierw obiekt 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 konkretnej playliście.
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
w taki 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/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,
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,
);
}
}
Jesteś prawie gotowy do uruchomienia tego kodu na Androidzie i iOS. Jeszcze jedno: zmodyfikuj stałą youTubeApiKey
w wierszu 14 za pomocą klucza interfejsu API YouTube wygenerowanego w poprzednim kroku.
lib/main.dart
// TODO: Replace with your YouTube API Key
const youTubeApiKey = 'AIzaNotAnApiKey';
Jeśli chcesz uruchomić tę aplikację w systemie macOS, musisz ją włączyć w celu wysyłania żądań HTTP w podany niżej sposób. Edytuj 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>
Uruchom aplikację
Po ukończeniu aplikacji powinno być możliwe jej uruchomienie w emulatorze Androida lub symulatorze iPhone'a. Zobaczysz listę playlist Flutter. Po wybraniu playlisty zobaczysz znajdujące się na niej filmy. Gdy klikniesz przycisk Odtwórz, otworzy się YouTube, gdzie możesz obejrzeć film.
Jeśli jednak spróbujesz uruchomić ją na komputerze, po rozwinięciu do okna o normalnym rozmiarze komputera układ poczuje się niewłaściwie. W następnym kroku dowiesz się, jak dostosować się do tej zmiany.
5. Dostosowywanie do pulpitu
Problem z pulpitem
Jeśli uruchomisz aplikację na jednej z natywnych platform komputerowych, czyli w systemie Windows, macOS lub Linux, zauważysz interesujący problem. Działa, ale wygląda na ... dziwne.
Rozwiązaniem tego problemu jest dodanie podzielonego widoku, w którym lista playlist znajduje się po lewej stronie, a filmy po prawej. Ten układ ma się jednak uruchamiać tylko wtedy, gdy kod nie jest uruchomiony na Androidzie lub iOS, a okno jest wystarczająco szerokie. Poniżej znajdziesz instrukcje, jak wdrożyć tę funkcję.
Najpierw dodaj pakiet split_view
, by ułatwić tworzenie układu.
$ flutter pub add split_view Resolving dependencies... + split_view 3.1.0 test_api 0.4.3 (0.4.8 available) Changed 1 dependency!
Przedstawiamy widżety adaptacyjne
Wzórem, którego użyjesz w tym ćwiczeniu w programie, będzie wprowadzenie widżetów adaptacyjnych, które dokonują wyboru implementacji na podstawie takich atrybutów jak szerokość ekranu, motyw platformy itp. W tym przypadku zaprezentujesz widżet AdaptivePlaylists
, który zmienia sposób interakcji usług 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,
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,
);
}
}
Następnie utwórz plik dla 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, uwzględnia on zarówno szerokość okna (za pomocą MediaQuery.of(context).size.width
), a Ty sprawdzasz motyw (za pomocą Theme.of(context).platform
), aby zdecydować, czy wyświetlić szeroki układ z widżetem SplitView
czy wąski wyświetlacz bez niego.
Druga sekcja dotyczy zakodowanej na stałe obsługi nawigacji. Wyświetla argument wywołania zwrotnego w widżecie Playlists
. To wywołanie zwrotne powiadamia otaczający kod, że użytkownik wybrał playlistę. Kod musi wykonać tę czynność, aby wyświetlić playlistę. Spowoduje to zmianę wymagań dotyczących funkcji Scaffold
w widżetach Playlists
i PlaylistDetails
. Teraz gdy nie są to widżety najwyższego poziomu, musisz usunąć z nich Scaffold
.
Następnie zmodyfikuj plik src/lib/playlists.dart
w ten sposób:
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 wprowadzono wiele zmian. Oprócz wspomnianego wcześniej wprowadzenia wywołania zwrotnego wyboru playlisty i wyeliminowania widżetu Scaffold
widżet _PlaylistsListView
zmienia się z bezstanowego na stanowy. Ta zmiana jest wymagana w związku z wprowadzeniem należącego do Ciebie obiektu ScrollController
, które musi zostać skonstruowane i zniszczone.
Wprowadzenie elementu ScrollController
jest interesujące, ponieważ w przypadku szerokiego układu dwa widżety ListView
znajdują się obok siebie. W telefonach komórkowych tradycyjnie używa się jednego elementu ListView
, więc może istnieć jeden trwały element przewijany, do którego wszystkie urządzenia ListView
podłączają i odłączają w trakcie cyklu życia. W świecie, w którym wiele elementów ListView
obok siebie ma sens, warto to zrobić na komputerze.
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 powyższego widżetu Playlists
, w tym pliku wprowadzono również zmiany związane z usunięciem widżetu Scaffold
i wprowadzeniem należącego do Ciebie elementu ScrollController
.
Uruchom aplikację ponownie.
Możesz uruchamiać aplikację na wybranym komputerze (Windows, macOS lub Linux). Teraz wszystko powinno działać zgodnie z oczekiwaniami.
6. Dostosowanie do sieci
O co chodzi z tymi obrazami?
Uruchomienie aplikacji w internecie wiąże się teraz z koniecznością przystosowania się do działania przeglądarek.
W konsoli debugowania znajdziesz subtelną 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 na rozwiązanie problemów z renderowaniem obrazów jest wprowadzenie usługi sieciowej proxy do dodania wymaganych nagłówków udostępniania zasobów na różnych platformach. Wyświetl terminal i utwórz serwer WWW Dart w następujący 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... http 1.1.2 (from dev dependency to direct dependency) js 0.6.7 (0.7.0 available) lints 2.1.1 (3.0.0 available) + shelf_cors_headers 0.1.5 Changed 2 dependencies! 2 packages have newer versions incompatible with dependency constraints. Try `dart pub outdated` for more information.
Istnieją obecnie zależności, które nie są już wymagane. Przytnij je w ten sposób:
$ dart pub remove args shelf_router Resolving dependencies... args 2.4.2 (from direct dependency to transitive dependency) js 0.6.7 (0.7.0 available) lints 2.1.1 (3.0.0 available) These packages are no longer being depended on: - http_methods 1.1.1 - shelf_router 1.1.4 Changed 3 dependencies! 2 packages have newer versions incompatible with dependency constraints. Try `dart pub outdated` for more information.
Następnie zmodyfikuj zawartość pliku server.dart tak, aby pasowała do tej:
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}');
}
Możesz uruchomić ten serwer w następujący sposób:
$ dart run bin/server.dart Server listening on port 8080
Możesz też skompilować go jako obraz Dockera, a potem uruchomić utworzony obraz Dockera 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 Flutter, by korzystać z serwera proxy CORS, ale tylko wtedy, gdy działasz w przeglądarce.
Para widżetów z możliwością dostosowania
Pierwsza z 2 widżetów dotyczy sposobu, w jaki aplikacja będzie używać 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 stałej kIsWeb
z powodu różnic między platformami środowiska wykonawczego. Drugi widżet z możliwością dostosowania zmienia działanie aplikacji w taki sam sposób jak inne strony internetowe. Użytkownicy przeglądarki oczekują możliwości wyboru tekstu.
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 adaptacje w całej bazie kodu:
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 zarówno widżety Image.network
, jak i widżety 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 dostosowałeś tylko widżet Image.network
. 2 widżety Text
zostały bez zmian. Jest to celowe, ponieważ jeśli dostosujesz widżety tekstowe, funkcja onTap
w ListTile
zostanie zablokowana po kliknięciu tekstu przez użytkownika.
Prawidłowe uruchomienie aplikacji w sieci
Po uruchomieniu serwera proxy CORS powinno być możliwe uruchomienie aplikacji w internetowej wersji, która powinna wyglądać mniej więcej tak:
7. Uwierzytelnianie adaptacyjne
W tym kroku powiększysz aplikację o możliwość uwierzytelnienia użytkownika, a potem wyświetlisz jego playlisty. Konieczne będzie użycie wielu wtyczek, aby obsługiwać różne platformy, na których aplikacja może działać, ponieważ obsługa protokołu OAuth przebiega zupełnie inaczej w przypadku Androida, iOS, internetu, systemu Windows, macOS i Linux.
Dodawanie wtyczek w celu włączenia uwierzytelniania Google
Zainstalujesz trzy pakiety do obsługi uwierzytelniania Google.
$ flutter pub add googleapis_auth google_sign_in \ extension_google_sign_in_as_googleapis_auth Resolving dependencies... + args 2.4.2 + crypto 3.0.3 + extension_google_sign_in_as_googleapis_auth 2.0.12 + google_identity_services_web 0.3.0+2 + google_sign_in 6.2.1 + google_sign_in_android 6.1.21 + google_sign_in_ios 5.7.2 + google_sign_in_platform_interface 2.4.4 + google_sign_in_web 0.12.3+2 + googleapis_auth 1.4.1 + js 0.6.7 (0.7.0 available) matcher 0.12.16 (0.12.16+1 available) material_color_utilities 0.5.0 (0.8.0 available) meta 1.10.0 (1.11.0 available) path 1.8.3 (1.9.0 available) test_api 0.6.1 (0.7.0 available) web 0.3.0 (0.4.0 available) Changed 11 dependencies! 7 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 komputerowe uwierzytelniają się przez przeglądarkę. Aby przeprowadzić uwierzytelnianie na Androidzie, iOS i w internecie, użyj pakietów google_sign_in
i extension_google_sign_in_as_googleapis_auth
. Drugi pakiet działa jako podkładka interoperacyjna między dwoma pakietami.
Zaktualizuj kod
Zacznij aktualizację od utworzenia nowej abstrakcji wielokrotnego użytku – widżetu AdaptiveLogin. Ten widżet można wykorzystywać wielokrotnie, dlatego wymaga odpowiedniej konfiguracji:
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(),
),
);
}
}
Ten plik wiele robi. Metoda build
na platformie AdaptiveLogin
wykonuje ciężką pracę. Ta metoda sprawdza platformę środowiska wykonawczego, wywołując zarówno metodę kIsWeb
, jak i Platform.isXXX
(dart:io
). W przypadku aplikacji na Androida, iOS i internetu tworzy instancję widżetu stanowego _GoogleSignInLogin
. W systemach Windows, macOS i Linux tworzy wystąpienie widżetu stanowego _GoogleApisAuthLogin
.
Korzystanie z tych klas wymaga dodatkowej konfiguracji, która pojawi się później, po zaktualizowaniu pozostałej części kodu pod kątem używania nowego widżetu. Zacznij od zmiany nazwy elementu FlutterDevPlaylists
na AuthedUserPlaylists
, aby lepiej odzwierciedlić jej nowe przeznaczenie i zaktualizowanie kodu, tak by odzwierciedlał fakt, że http.Client
został już przekazany po zakończeniu budowy. Na koniec 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
o nową nazwę podanego 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({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,
);
},
);
}
}
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,
useMaterial3: true,
).toTheme,
darkTheme: FlexColorScheme.dark(
scheme: FlexScheme.red,
useMaterial3: true,
).toTheme,
themeMode: ThemeMode.dark, // Or ThemeMode.System
debugShowCheckedModeBanner: false,
routerConfig: _router,
);
}
}
Zmiany w tym pliku odzwierciedlają zmianę od wyświetlania playlist z YouTube Flutter na wyświetlanie playlist uwierzytelnionego użytkownika. Chociaż kod jest już gotowy, musisz wprowadzić serię zmian w tym pliku oraz w plikach w odpowiednich aplikacjach uruchamiających, aby prawidłowo skonfigurować pakiety google_sign_in
i googleapis_auth
pod kątem uwierzytelniania.
Aplikacja wyświetla teraz playlisty w YouTube utworzone przez uwierzytelnionego użytkownika. Gdy funkcje są już gotowe, 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.
Konfiguruję zasób googleapis_auth
Pierwszym krokiem do skonfigurowania uwierzytelniania jest wyeliminowanie skonfigurowanego i używanego wcześniej klucza interfejsu API. W projekcie API otwórz stronę danych logowania i usuń klucz interfejsu API:
Pojawi się wyskakujące okienko, w którym potwierdzasz, że klikasz przycisk Usuń:
Następnie utwórz identyfikator klienta OAuth:
Jako Typ aplikacji wybierz Aplikacja komputerowa.
Zaakceptuj nazwę i kliknij Utwórz.
Spowoduje to utworzenie identyfikatora klienta i klucza klienta, które musisz dodać do usługi 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 WWW działającego na lokalnym hoście do przechwycenia 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ż uprawnienie com.apple.security.network.server
do włączania funkcji Hot Załaduj ponownie i narzędzi debugowania maszyny wirtualnej Dart.
Teraz możesz uruchomić aplikację w systemach Windows, macOS lub Linux (jeśli aplikacja została skompilowana na te środowiska docelowe).
Konfigurowanie aplikacji google_sign_in
na Androida
Wróć na stronę danych logowania w projekcie API i utwórz kolejny identyfikator klienta OAuth. Tym razem wybierz Android:
W pozostałej części formularza wpisz nazwę pakietu za pomocą pakietu zadeklarowanego w polu android/app/src/main/AndroidManifest.xml
. Jeśli zostało przez Ciebie wykonane zgodnie ze wskazówkami dotyczącymi litery, to com.example.adaptive_app
. Wyodrębnij odcisk cyfrowy certyfikatu SHA-1, postępując zgodnie z instrukcjami ze strony pomocy konsoli Google Cloud Platform:
To wystarczająco dużo, aby aplikacja działała na Androidzie. W zależności od tego, których interfejsów API Google używasz, konieczne może być dodanie wygenerowanego pliku JSON do pakietu aplikacji.
Konfiguruję google_sign_in
na iOS
Wróć na stronę danych logowania w projekcie API i utwórz inny identyfikator klienta OAuth. Tym razem wybierz iOS:
,
W pozostałej części formularza wypełnij identyfikator pakietu, otwierając plik ios/Runner.xcworkspace
w Xcode. Przejdź do Nawigatora projektów, wybierz Runner w nawigatorze, kliknij kartę Ogólne i skopiuj identyfikator pakietu. Jeśli krok po kroku został przez Ciebie wykonane ćwiczenie w Codelabs, powinno to być com.example.adaptiveApp
.
Podaj identyfikator pakietu w pozostałej części formularza. Otwórz plik ios/Runner.xcworkspace
w Xcode. Otwórz Projekt Navigator. Wybierz Runner > Karta Ogólne. Skopiuj identyfikator pakietu. Jeśli krok po kroku został przez Ciebie wykonane ćwiczenie w Codelabs, jego wartość powinna wynosić com.example.adaptiveApp
.
Na razie zignoruj identyfikator App Store i identyfikator zespołu, ponieważ nie są one wymagane do lokalnego programowania:
Pobierz wygenerowany plik .plist
. Jego nazwa bazuje 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 lewym nawigatorze. W oknie opcji w Xcode wybierz w razie potrzeby Kopiuj elementy, Utwórz odwołania do folderów i Dodaj do środowiska docelowego.
Wyjdź z Xcode, a następnie w dowolnym IDE dodaj do 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>
Musisz zmienić wartość, aby pasowała do wpisu w wygenerowanym pliku GoogleService-Info.plist
. Uruchom aplikację. Twoje playlisty powinny pojawić się po zalogowaniu.
Konfiguruję google_sign_in
na potrzeby sieci
Wróć na stronę z danymi logowania w projekcie API i utwórz inny identyfikator klienta OAuth (tym razem nie ograniczaj się do opcji Aplikacja internetowa):
W pozostałej części formularza wypełnij autoryzowane źródła JavaScriptu w ten sposób:
Spowoduje to wygenerowanie identyfikatora klienta. Dodaj do pliku web/index.html
ten tag meta
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">
Aby uruchomić tę próbkę, musisz trzymać rękę na pulsie. Musisz uruchomić serwer proxy CORS utworzony w poprzednim kroku i uruchomić aplikację internetową Flutter na porcie określonym w formularzu identyfikatora klienta OAuth aplikacji internetowej, postępując zgodnie z poniższymi instrukcjami.
W jednym z terminali 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".
Gdy zalogujesz się jeszcze raz, Twoje playlisty powinny być widoczne:
8. Dalsze kroki
Gratulacje!
Udało Ci się ukończyć ćwiczenia i stworzyć adaptacyjną aplikację Flutter, która będzie działać na wszystkich 6 platformach obsługiwanych przez Flutter. Kod został dostosowany, aby uwzględnić różnice w układzie ekranów, interakcji z tekstem, sposobie ładowania obrazów oraz działaniu uwierzytelniania.
Istnieje wiele innych elementów, które możesz dostosować w swoich aplikacjach. Aby poznać dodatkowe sposoby dostosowywania kodu do różnych środowisk, w których będzie uruchamiany, zapoznaj się z sekcją Tworzenie aplikacji adaptacyjnych.