Aplikacje adaptacyjne w technologii Flutter

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:

Gotowa aplikacja uruchomiona w emulatorze Androida

Gotowa aplikacja działająca w symulatorze iOS

Aplikacja uruchomiona w trybie panoramicznym w systemie macOS powinna wyglądać jak na zrzucie ekranu poniżej.

Gotowa aplikacja działająca na macOS

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?

Nie znam tego tematu i chcę uzyskać ogólne informacje. Wiem coś na ten temat, ale chcę sobie przypomnieć najważniejsze informacje. Szukam przykładowego kodu do wykorzystania w moim projekcie. Szukam wyjaśnienia konkretnej kwestii.

2. Konfigurowanie środowiska programistycznego Fluttera

Do ukończenia tego modułu potrzebne są 2 programy: pakiet Flutter SDKedytor.

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:

Wyświetlanie właściwości okna na emulatorze Androida

Wyświetlanie właściwości okna w symulatorze iOS

A oto ten sam kod uruchomiony natywnie w systemie macOS i w przeglądarce Chrome, również w systemie macOS.

Wyświetlanie właściwości okna w macOS

Wyświetlanie właściwości okna w przeglądarce Chrome

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 ThemeBuildContext 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 w flex_color_schemedokumentacji 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:

Wyświetlanie konsoli GCP podczas tworzenia projektu

Gdy masz już projekt, otwórz stronę Biblioteka interfejsów API. W polu wyszukiwania wpisz „youtube” i wybierz youtube data api v3.

Wybieranie interfejsu YouTube Data API v3 w konsoli GCP

Na stronie szczegółów interfejsu YouTube Data API v3 włącz interfejs API.

5a877ea82b83ae42.png

Po włączeniu interfejsu API otwórz stronę Dane logowania i utwórz klucz interfejsu API.

Tworzenie danych logowania w konsoli GCP

Po kilku sekundach powinien pojawić się dialog z nowym kluczem interfejsu API. Ten klucz będzie potrzebny wkrótce.

Wyskakujące okienko z informacją o utworzeniu klucza interfejsu API i wyświetleniem utworzonego klucza

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.

Aplikacja z playlistami na koncie YouTube FlutterDev

wyświetlanie filmów z określonej playlisty,

Wybrany film odtwarzany w odtwarzaczu YouTube

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.

Aplikacja działająca w systemie macOS, która wyświetla listę playlist w dziwnych proporcjach

Filmy na playliście w systemie macOS

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 AdaptivePlaylistswidżet, który zmienia sposób interakcji PlaylistsPlaylistDetails. 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 PlaylistsPlaylistDetails. 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.

Aplikacja działająca w systemie macOS w widoku dzielonym

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.

Aplikacja działająca w przeglądarce Chrome bez miniatur obrazów z YouTube

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.networkText. 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:

Aplikacja działająca w przeglądarce Chrome z wypełnionymi miniaturami obrazów z YouTube

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_inextension_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_ingoogleapis_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_ingoogleapis_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:

Strona danych logowania projektu interfejsu API w konsoli GCP

Spowoduje to wyświetlenie okna dialogowego, które możesz potwierdzić, klikając przycisk Usuń:

Wyskakujące okienko Usuń dane logowania

Następnie utwórz identyfikator klienta OAuth:

Tworzenie identyfikatora klienta OAuth

Jako typ aplikacji wybierz Aplikacja na komputer.

Wybieranie typu aplikacji na komputer

Zaakceptuj nazwę i kliknij Utwórz.

Nadawanie nazwy identyfikatorowi klienta

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).

aplikacja wyświetlająca playlisty zalogowanego użytkownika;

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:

Wybieranie typu aplikacji na Androida

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:

Nadawanie nazwy identyfikatorowi klienta Androida

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.

Uruchamianie aplikacji na Androidzie

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:

Wybieranie typu aplikacji na 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.

Gdzie znaleźć identyfikator pakietu w Xcode

Na razie zignoruj identyfikator App Store i identyfikator zespołu, ponieważ nie są one wymagane w przypadku lokalnego tworzenia aplikacji:

Nadawanie nazwy identyfikatorowi klienta iOS

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).

Dodawanie wygenerowanego pliku plist do aplikacji na iOS w Xcode

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.

Aplikacja działająca na iOS

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:

Wybieranie typu aplikacji internetowej

W pozostałej części formularza wypełnij pole Autoryzowane źródła JavaScriptu w ten sposób:

Nadawanie nazw identyfikatorom klientów aplikacji internetowych

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:

aplikacja działająca w przeglądarce Chrome;

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.