- 投稿日:2020-05-16T22:42:20+09:00
マージされたAndroidManifest.xmlの内容を表示する
普段編集しているAndroidManifest.xmlがありますが、実際にapkに組み込まれるAndroidMaifest.xmlは外部ライブラリやFlavorのManifestとマージされた結果のxmlとなります。
AndroidStudioでAndroidManifest.xmlを開き、下のタブで
Merged Manifest
を選択することで、マージ結果のManifestを確認することができます。
左クリックから要素がどのManifestから来ているのか追うこともできます。
- 投稿日:2020-05-16T22:42:06+09:00
実行中のadb server/Unity組込み/Android Studio/環境変数からadbを探して実行するPowerShell関数
はじめに
PCに複数のadbやAndroid SDKがインストールされている時に起動中のadb serverと違うバージョンのadbを実行するとadb serverが再起動してしまいます。そのため、なるべく同じバージョンのadbを実行するPowerShellの関数を作りました。
スクリプト
PowerShellの
$profile
に以下のスクリプトを追加してadbを実行すると以下の順番でadbコマンドを探します。
- 実行中のadb server
- 実行中のUnityから一緒にインストールしたAndroid SDKのadb
- Android StudioからインストールしたAndroid SDKのadb
- 環境変数PATHのadb
function FindAdb() { # find from process list $adbPath = Get-Process -Name "adb" -ErrorAction Ignore | Sort-Object Id | Select-Object -First 1 -ExpandProperty Path if ($null -ne $adbPath) { return $adbPath } # find from Unity in process list $adbPath = Get-Process -Name "Unity" -ErrorAction Ignore | Select-Object @{ label="adb" expression={ Join-Path (Split-Path $_.Path) "Data" "PlaybackEngines" "AndroidPlayer" "SDK" "platform-tools" "adb.exe" } } | Where-Object { Test-Path -Path $_.adb -PathType Leaf } | Select-Object -First 1 -ExpandProperty adb if ($null -ne $adbPath) { return $adbPath } # find from LOCALAPPDATA $adbPath = Join-Path $env:LOCALAPPDATA "Android" "Sdk" "platform-tools" "adb.exe" if (Test-Path -Path $adbPath -PathType Leaf) { return $adbPath } # find from path $adbPath = Get-Command -Name "adb" -CommandType Application,ExternalScript -ErrorAction Ignore | Select-Object -First 1 -ExpandProperty Source if ($null -ne $adbPath) { return $adbPath } # not found return "adb.exe" } function ExecAdb() { $adb = FindAdb & "$adb" $args } Set-Alias -Name adb -Value ExecAdb
- 投稿日:2020-05-16T22:03:02+09:00
眺めて覚える C# Xamarin Forms(13) File 操作
Android上の任意のファイルアクセスするときにユーザーの許可が必要です。
主なAndroidのファイルディレクトリ
Android.OS.Environment ディレクトリ DirectoryAlarms PRIVATE_EXTERNAL_STORAGE/Alarms DirectoryDcim PRIVATE_EXTERNAL_STORAGE/DCIM DirectoryDownloads PRIVATE_EXTERNAL_STORAGE/Download DirectoryDocuments PRIVATE_EXTERNAL_STORAGE/Documents DirectoryMovies PRIVATE_EXTERNAL_STORAGE/Movies DirectoryMusic PRIVATE_EXTERNAL_STORAGE/Music DirectoryNotifications PRIVATE_EXTERNAL_STORAGE/Notifications DirectoryPodcasts PRIVATE_EXTERNAL_STORAGE/Podcasts DirectoryRingtones PRIVATE_EXTERNAL_STORAGE/Ringtones DirectoryPictures PRIVATE_EXTERNAL_STORAGE/Pictures 許可なしでアクセスすると下記のメッセージを出し例外として扱われます。
手順1 Xamarin formsのプロジェクトを作成します。
手順2 andoridプロジェクトのプロパティを開きます。
手順3 ファイルアクセスするためには、android マニフェストを開いて、必要なアクセス許可を与えます。
手順4 参照にMono.Abdroid.dllを追加します。
手順5MainActivity.csに許可ダイアログ要求を追加します。
MainActivity.csusing System; using Android.App; using Android.Content.PM; using Android.Runtime; using Android.Views; using Android.Widget; using Android.OS; using Android.Support.V4.App; using Android.Support.V4.Content; using Android; namespace PCLFiles.Droid { [Activity(Label = "PCLFiles", Icon = "@mipmap/icon", Theme = "@style/MainTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation)] public class MainActivity : global::Xamarin.Forms.Platform.Android.FormsAppCompatActivity { protected override void OnCreate(Bundle savedInstanceState) { TabLayoutResource = Resource.Layout.Tabbar; ToolbarResource = Resource.Layout.Toolbar; base.OnCreate(savedInstanceState); //追加します。 if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.ReadExternalStorage) != (int)Permission.Granted) { ActivityCompat.RequestPermissions(this, new string[] { Manifest.Permission.ReadExternalStorage }, 0); } if (ContextCompat.CheckSelfPermission(this, Manifest.Permission.WriteExternalStorage) != (int)Permission.Granted) { ActivityCompat.RequestPermissions(this, new string[] { Manifest.Permission.WriteExternalStorage }, 0); } Xamarin.Essentials.Platform.Init(this, savedInstanceState); global::Xamarin.Forms.Forms.Init(this, savedInstanceState); LoadApplication(new App()); } public override void OnRequestPermissionsResult(int requestCode, string[] permissions, [GeneratedEnum] Android.Content.PM.Permission[] grantResults) { Xamarin.Essentials.Platform.OnRequestPermissionsResult(requestCode, permissions, grantResults); base.OnRequestPermissionsResult(requestCode, permissions, grantResults); } } }手順6 MainPage.xamlにスタートボタンを設定します。
MainPage.xaml<?xml version="1.0" encoding="utf-8" ?> <ContentPage xmlns="http://xamarin.com/schemas/2014/forms" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:d="http://xamarin.com/schemas/2014/forms/design" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d" x:Class="PCLFiles.MainPage"> <StackLayout> <!-- Place new controls here --> <Label Text="Welcome to Xamarin.Forms!" HorizontalOptions="Center" /> <Button Text="Start" Clicked="SetUp"/> </StackLayout> </ContentPage>手順7 ライブラリを追加しMainPage.xaml.csにファイル読み込み部を追加します。
MainPage.xaml.csusing System; using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Text; using System.Threading.Tasks; using Xamarin.Forms; using Android.OS; using System.IO; using Android.Support.V4.App; using Android.Support.V4.Content; using Android; namespace PCLFiles { public class Cam { public string Name { get; set; } public string Image { get; set; } } // Learn more about making custom code visible in the Xamarin.Forms previewer // by visiting https://aka.ms/xamarinforms-previewer [DesignTimeVisible(false)] public partial class MainPage : ContentPage { public MainPage() { InitializeComponent(); } private void SetUp(object s,EventArgs e) { var tmp = new DataTemplate(() => { var grid = new Grid() { Margin = 2 }; grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(100) }); grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Auto) }); var name = new Label { FontSize = 16 }; name.SetBinding(Label.TextProperty, "Name"); var image = new Image() { HeightRequest = 200 }; image.SetBinding(Image.SourceProperty, "Image"); grid.Children.Add(image, 0, 0); grid.Children.Add(name, 1, 0); return new ViewCell { View = grid }; }); var path = Android.OS.Environment.GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDcim).Path + "/Camera"; var lst = from x in Directory.GetFiles(path).Take(10) select new Cam() { Name = Path.GetFileName(x), Image = x }; var lv = new ListView() { ItemsSource = lst, ItemTemplate = tmp }; Content = new Xamarin.Forms.ScrollView() { Margin = 2, Orientation = ScrollOrientation.Vertical, Content = lv }; } } }手順8 実行する。
付録
nugetで追加したライブラリ
- 投稿日:2020-05-16T20:59:59+09:00
Flutter Web View で Localの HTML , CSSを表示する
はじめに
皆さんはFlutter触ってますか?
Googleが開発しているモバイル向けクロスプラットフォーム,かつWebまでかけちゃうぜ!!ってことで注目しいましたが,思ったとおりにできることも増えてきて,「もうアプリはFlutterで良いじゃん」も近いのかなと思っている今日このごろ.
今回はLocal(端末)に保存したHtml, Css の Web View 表示で手間取ったのでまとめていきます.Web Viewって?
アプリ内でWebページを表示できる機能です.
アプリによってはWeb Viewだけで実装しているようなのもちらほら...
そんなわけで結構普通に必要な機能です.
今回は端末内に保存したHtmlを表示させることが目的です.
Flutterではこちらのパッケージを使うことで実装できます.環境
$ flutter doctor Doctor summary (to see all details, run flutter doctor -v): [✓] Flutter (Channel stable, v1.12.13+hotfix.5, on Mac OS X 10.15.4 19E287, locale ja-JP) [✓] Android toolchain - develop for Android devices (Android SDK version 29.0.2) [✓] Xcode - develop for iOS and macOS (Xcode 11.4.1) [✓] Android Studio (version 3.6) [!] IntelliJ IDEA Ultimate Edition (version 2020.1.1) ✗ Flutter plugin not installed; this adds Flutter specific functionality. ✗ Dart plugin not installed; this adds Dart specific functionality. [✓] Connected device (1 available)導入&問題発生
いつもどおりパッケージを入れて導入しました.
HTMLも/assets/html/view.htmlに配置して,pubspecに追記.pubspec.yamldependencies: flutter: sdk: flutter webview_flutter: ^0.3.21 cupertino_icons: ^0.1.2 dev_dependencies: flutter_launcher_icons: ^0.7.5 flutter_test: sdk: flutter pedantic_mono: any flutter: uses-material-design: true assets: - assets/icon/ - assets/html/ flutter_icons: android: true ios: true image_path: 'assets/icon/icon.png' adaptive_icon_foreground: 'assets/icon/icon_foreground.png' adaptive_icon_background: "#ffa103"iOSでの設定のために /iOS/Runner/info.plist に追記
info.plist<key>io.flutter.embedded_views_preview</key>Mainをこちらを参考に書く.
main.dartimport 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:webview_flutter/webview_flutter.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { final String pageTitle = 'Web View Local'; @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'Web View', theme: ThemeData( primarySwatch: Colors.amber, ), home: WebViewPage(title: pageTitle), ); } } class WebViewPage extends StatefulWidget { const WebViewPage({Key key, this.title}) : super(key: key); final String title; @override _WebViewState createState() => _WebViewState(); } class _WebViewState extends State<WebViewPage> { WebViewController _controller; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), ), body: WebView( onWebViewCreated: (WebViewController webViewController) async { // 生成されたWebViewController情報を取得する _controller = webViewController; // HTMLファイルのURL(ローカルファイルの情報)をControllerに追加する処理 await _loadHtmlFromAssets(); }, // javascriptを有効化 javascriptMode: JavascriptMode.unrestricted, ), ); } /// HTMLファイルを読み込む処理(非同期) Future _loadHtmlFromAssets() async { //HTMLファイルを読み込んでHTML要素を文字列で返す final fileText = await rootBundle.loadString('assets/html/view.html'); await _controller.loadUrl( Uri.dataFromString( fileText, mimeType: 'text/html', encoding: Encoding.getByName('utf-8') ).toString()); } }これでDebugしてみたのですが... 動かない!! Androidだけ!!
問題解決
そこでふと思い出したのですが,前にFlutterに学習済みモデル(.tflite)を入れた時,Androidに拡張子を認識させる呪文を唱えた気がします.
そこで物は試しにと /android/app/src/build.gradle にbuild.gradle: : buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } //ここを追記 aaptOptions { noCompress "html" } }と書いてみると,表示された!!
どうやらapk作成の際に中に html が取り込まれてしまっていたみたいです.
無事解決!!おわりに
ということでなんとか問題は解決しました.
ちなみに別ファイルに分けていたcssを読み込んでくれない,などのトラブルは有りましたが,これもinline表記することで解決しました.
めでたしめでたし!!参考資料(ありがとうございます!!)
【Flutter】WebViewを使ってWebサイトとローカルHTMLファイルを表示させる方法
Google公式Flutter用WebViewプラグインを一通り使ってみた
Loading Local Assets in WebView in Flutter
- 投稿日:2020-05-16T18:53:06+09:00
Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(12)ユーザー認証編(Firebase Authentication)
前回の続きです。
Firebaseのクラウドデータベースである、Firestoreを使う準備として、ユーザー認証を行います。
ユーザー認証には、Firebase Authenticationを使います。今回の目標
Firebase AuthenticationのFirebaseUI Authを使ってユーザー認証(ログイン/ログアウト)が出来る。様々な認証フローが使える。
環境など
AndroidStudioを3.6.3に上げました。
Firebase Authentication
Firebase Authenticationには、2つの実装手段が用意されています。
FirebaseUI Authと、Firebase Authentication SDKです。
https://firebase.google.com/docs/auth?authuser=01.FirebaseUI Auth
ドロップイン認証ソリューション
と書かれています。
様々なログイン方法(メールアドレス+パスワード/TwitterなどのSNS認証/電話番号認証)などでの認証フローを全部やってくれるものです。要は、認証ページから何から、すべてお任せ出来るってことです。
アプリですることは、認証UIを呼び出す、ログアウトページからログアウトできるようにしておく、程度のことになります。
このアプリでは、こちらを使います。2.Firebase Authentication SDK
様々な種類の認証フローを、自分で組み合わせて適宜呼び出す方法です。UIはすべて自作する必要があり、またSNS認証を行う場合は、OAuthトークンなどは自分で貰ってきてSDKにわたすと言うことが必要になります。
こちらは、ログイン画面は既に他で作っていて(変えづらく)、認証フローを追加せざるを得なくなった場合や、認証フローの中に独自の処理を挟まなければならないような場合に使えるかと思います。
3.使える認証方法
FirebaseUI Authで使える認証方法/認証プロバイダは、以下の物があります。
- メール+パスワード
- 電話番号
- Apple
- Microsoft
- GitHub
- Yahoo
詳しくはこちらに。
https://github.com/firebase/FirebaseUI-Android/blob/master/auth/README.mdこのうち、Facebookは、Facebookへの開発者登録が必要になります。
また、Facebookのみ、FacebookSDKをアプリに設定する必要があります。準備
1.証明書のSHA-1情報の取得
まず準備として、証明書のSHA-1が必要になるログインフローがあるので、それを取得して設定しておきましょう
(1)デバッグビルド用の証明書を作ってない場合
デバッグビルド用の証明書を自前で用意していない場合は、作ってください。
というのも、AndroidStudioがデフォルトで使う開発者証明書は、開発マシンによって異なるからです。AndroidStudioをアップデートしたり、PCのOSをアップデートしたりすると変わってしまう可能性もあります。例え個人でやっていてもその都度書き変えるのは現実的ではありません。
なので、デバッグビルド用の証明書も作ってしまっておきましょう。作成方法は、こちらなどを参考にしてください。
Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(10)リリースビルドとGithub Actions編 #3デバッグ証明書を作成する(2)デバッグビルド用の証明書がある場合
証明書のあるフォルダ(
app
)まで移動して、次のようにコマンドを打ちます。$ keytool -exportcert -list -v \ -alias エイリアス名 -keystore <path-to-debug-keystore>パスワードを聞かれるので、キーストアのパスワードを入力します。
このシリーズの手順でやってきた場合、デバッグビルド用の証明書は、エイリアス名=
androiddebugkey
、パスワードはandroid
のはずです。
それ以外の人は、作ったときの情報を思い出して下さい。(忘れていたら証明書の作り直しですw)次のように出力されるので、証明書のフィンガープリントのうち、SHA1となっている部分をコピーします。(SHA256でも多分大丈夫です。お好みで)
続いて、Firebaseコンソールのプロジェクト設定ページで、[全般]タブの下の方にある[マイアプリ]で、デバッグ用のアプリを選びます。
- [フィンガープリントを追加]をクリック
- 先ほどコピーした値を貼り付けて保存する
(3)リリースビルド用の証明書がある場合
デバッグビルド用の証明書と同じ手順で、リリース証明書のSHA1(またはSHA256)をコピーしてセットします。
このシリーズをやってきて
key.properties
ファイルを作ってある人は、その中の情報を参照できますね。両方セットできたら準備は完了です。
2.Facebookの開発者登録
Facebookログインを入れない人は飛ばして結構です。
こちらのページから登録を開始します。
https://developers.facebook.com/なお、このアカウントは、通常のFacebookアカウントとは別に作成してます。
多分別にした方が良いかと思います。(1)氏名など情報を入力する
(2)メールが届くので、認証コードを入力する
認証するとこのように表示されます。
ショートカットは要らないでしょう。[後で]をクリックします。
(3)開発アカウント認証をする
上記手順だと、普通のFacebookページに遷移してしまうので、改めて以下を表示します。
https://developers.facebook.com/tools
- 右上の[スタート]をクリック
- [次へ]をクリック
アカウント認証の実行をする
- SMSまたは電話のどちらかお好きな方で
- SMSにする場合、電話帳未登録を拒否している場合は解除が必要です
ロールを選ぶ
- 私は「開発者」にしました
- 「最初のアプリを作成」をクリック
- アプリの表示名を入力して[アプリIDを作成してください]をクリック
- ロボットチェックをこなす
- 開発アプリページが開く
(4)開発アプリを登録する
- デバッグ証明書のSHA1ハッシュをbase64エンコードした文字列を以下のコマンドで出力し、コピーしておく
$ cd app $ keytool -exportcert -alias エイリアス名 -keystore <path-to-debug-keystore> | openssl sha1 -binary | openssl base64
- 左側のメニューにある、[設定]を開き、[ベーシック]をクリック
- 右のページの下までスクロールすると、[+プラットフォームを追加]があるので、それをクリック
- [Android]を選ぶ
- 必要な情報を入力する
- Google Playパッケージ名 : debugアプリのパッケージ名です。suffixID付けている場合は付け忘れないように注意
- クラス名 : アプリの起動画面です。Splash等を用意している場合はそちらのクラス名を指定します。これを後から変更するのはアプリの再申請(Facebook側でもアプリをレビューします)が必要となるため、一度リリース後は変えられないと思った方が良いです。
- キーハッシュ : デバッグ用証明書のSHA1のキーハッシュを貼り付ける
- その他はデフォルトのままでOK.
- [変更を保存]をクリック
- Playストアにパッケージがないと言われますが、リリース前なので当然です。無視して[このパッケージ名を使用する]をクリックして下さい
- [設定]-[詳細設定]を開く
- ネイティブアプリまたはデスクトップアプリをはいにする
- [製品を追加]にある、[Facebookログイン]をクリック
いったんは以上でOKです。
3.Twitter開発者登録
Twitterログインを入れない場合は飛ばして結構です。
(1)開発用アカウントを作成する
多分、個人のアカウントとは分けた方が良いです。
(2)開発者申請する
下記のページで、先ほど作ったアカウントでログインします。
https://dev.classmethod.jp/articles/twitter-developer/
- [Create an app]をクリック
- [Apply]をクリック
- よく読んでくださいね。
- 目的を聞かれるので、自分に合った物を選んで[Next]をクリック
- 無料でリリースするアプリはどれが一番いいんだろう?と思いますが、[Building consumer producs]としてみました。
- [Add a valid phone number]をクリックして電話番号認証を行う
- SMSのアドレス帳登録以外を拒否や非通知にしている場合は気をつけて下さい。
- 戻ってページをリロードする
- Team developer accountの[Switch to an indivitual developer account]をクリック
- 業務で作成する場合は、そのままにして次のページで必要情報を入力して下さい。
- 下に項目が追記されるのでスクロールして表示
- What country do you live in? : Japanを選択
- What would you like us to call you? : 開発アカウント名を入力(多分サポートとやりとりするのに使うのかと)
- Want updates about the Twitter API? : TwitterAPIの更新通知を受け取るかどうか任意でセット
Nextをクリック
アプリの目的などを入力する
- 以下は私の場合です。丸々同じだとスパムアプリのように思われるので、ご自分で考えて書くか、もっとちゃんとした英語の出来る人に考えて貰いましょうw
- [Next]をクリックすると、確認画面になるので、内容をもう一度確認して、問題なければ[Looks good!]をクリック
- メールを認証しろと出るので、メールのリンクをクリックして認証を完了させる
- これ忘れるとレビューが進まないようですのでご注意を。
なお、日本語でも申請可能だという記事もありましたが、承認に時間がかかったり、もう一度やりとりする必要が生じる可能性があるとのことです。
https://dev.classmethod.jp/articles/twitter-developer/※私は英語でやり、直ぐに承認メールが来ました。
(3)開発アプリを登録する
- 開発者ページにログイン
- [Create an app]をクリック
- 右上の[Create an app]をクリック
- アプリ名を入力
- アプリの説明(ユーザーが読む)を入力
Wbesite URL
- 必須です。Firebase Hostingとか、無料のホームページツールとかで、適当なページを作ってアプリの説明を書いて置いておきましょう。リリース済みのアプリなら、Playストアへのリンクとかがあってもいいですね。取り敢えず、今回はFirebase Hostingで簡単なページを作っておきました。CSSとか苦手なんで酷いページですw
- Firebase Hostingの導入については、こちらなどを参考にして下さい。 Firebase HostingでFlutterアプリのプライバシーポリシーのページを作る
Enable Sign in with Twitter
- 今はチェックしないでおきます。
- アプリの説明(Twitter社が読む)を英語で記入
[Create]をクリック
- クリックできないときは、文字数を満たしてないなど不十分な項目があります。
規約をよく読んで、同意できる場合は[Create]をクリック
作成できたら、今はいったんここまででOKです。
認証の設定と実装
1.Firebaseコンソールで認証方法を設定
(1)認証フローの決定
今回は、以下でやってみようと思います。
- メール+パスワード
- GitHub
手順はほとんど同じになってくるので、お好みで良いと思います。
個人的には、MicrosoftやAppleログインがあるのが、へえー!と思いました^^;
なお、iOSアプリの場合、ログイン機能があるアプリにはAppleログインが必須なようですね。(2)メールログインの設定
- [ログイン方法を設定]をクリック
- [Sign-in method]タブを選ぶ
- メール/パスワードにカーソルを合わせ、右に出てきたエディットアイコンをクリック
- トグルボタンをクリックして、[有効]にする
- メールリンクは、無効にしておきます(仕様次第)
- パスワード再発行などもやってくれるんですね。至れり尽くせりです!
- [保存]をクリック
(3)Googleログインの設定
Androidスマホを持っている人なら持って無いはずはないGoogleログインを使わない手はありませんね。
同じように、[Sign-in method]のタブから追加していきます。
- Googleの行にマウスポインタをホバリングしてエディットアイコンをタップ
- トグルボタンをクリックして、[有効]にする
プロジェクト設定のアプリ名やサポートメールを設定するように出た場合は、ここで設定できます。ただ、メールアドレスはログインしているアカウントのものしか選べません。もし、サポートメールのアドレスを変更したい場合には、そのアドレスを持つ「オーナー」アカウントをもう一名Firebaseプロジェクトに招待して、そちらのアカウントでFirebaseコンソールにログインする必要があります。
以下の手順は任意です。今のアドレスで問題ない人は、そのメールアドレスを設定して、[保存]ボタンをクリックして下さい。
オーナーアカウントを追加するには、プロジェクト設定ページの右上の、[ユーザーと権限]タブを表示します。
招待したアカウントに招待メールが届くので、そのアカウントで、Firebaseコンソールにログインします。
そして、そのアカウントで、プロジェクト設定のページを開きます。サポートメールのところで、ドロップダウンリストを表示すると、メールアドレスを変更できます。(一度そのアカウントものに変えると、もう一度他のものへの変更は出来ません。また変更したい方のアカウントからログインして行う必要があります。)
FirebaseUI Auth設定のページに戻って、リロードします。
Googleログインの設定編集ページを開き、有効にして、[保存]をクリックします。(4)Facebookログインの設定
- Facebookの編集アイコンをクリックします。
- トグルボタンをクリックして、[有効]にする
- アプリケーションIDをFacebook開発者ページからコピーして貼り付ける
- アプリシークレットも、Facebook開発者ページにあります。左側のメニューから、設定の[ベーシック]を開くと、右側にあります。
- OAuthリダイレクトURLをコピーして、[保存]をクリック
次に、Facebook開発者ページでOAuthリダイレクトURLを設定します。
- 左側のメニューの[Facebookログイン]のリストを開き、設定を選ぶ
- 有効なOAuthリダイレクトURIに、先ほどコピーしたURLを貼り付ける
- [変更を保存]をクリック
(5)Twitterログインを設定
- Twitterの開発アプリのページから、アプリの[Details]を開く
- [Keys and tokens]タブを開く
- API keyとAPI secret keyをコピーしておく
- Firebaseコンソールの[Authenticatoin]-[Sign-in method]で、Twitterの編集アイコンをクリックする
トグルボタンをクリックして、[有効]にする
- Twitterの開発アプリのAPI keyを
APIキー
に貼り付ける- Twitterの開発アプリのAPI secret keyを
APIシークレット
に貼り付けるコールバックURLをクリックする
[保存]をクリック
- Twitterの開発アプリのページから、アプリの[Details]を開く
- [Edit]-[Edit details]をクリック
- Enable Sign in with Twitterにチェックを入れる
- Callbak URLsに先ほどコピーしたURLを貼り付ける
- [Save]をクリック
(6)Githubログインを設定
一般ユーザーはこれよりYahooがあった方が良いような気がするけどもうこれ以上開発アカウントを作るのもアレなので。Githubならこの記事の読者はみんな持ってるよね。
- Firebaseコンソールの[Authenticatoin]-[Sign-in method]で、Githubの編集アイコンをクリックする
- トグルボタンをクリックして、[有効]にする
- 認証コールバックURLをコピーする
いったんGithubのページへ行きます。
必要なアカウントでログインして下さい。
https://github.com/settings/applications/new
- 必要な情報を入力 ここにもHomepageURLがあるので、Twitter用に作ったページを設定できますね。
- Authorization callback URLに、先ほどコピーしたURLを貼り付ける
- [Register app]をクリック
- 表示されたページのCllient IDとClient Secretをメモする
Firebaseに戻ります。
- クライアントIDとクライアントシークレットに、GithubのClient IDとClient Secretをそれぞれ貼り付ける
- [保存]をクリック
お疲れ様でした。準備はこれで終わりです。
やっとAndroidアプリの方に取りかかります。3.AndroidアプリにFirebaseUI Authを導入する
(1)依存関係の追加
app/build.gradle
に以下を追記します。
FacebookのSDKはFacebookログインを入れない場合は不要です。app/build.gradleimplementation 'com.firebaseui:firebase-ui-auth:6.2.1' implementation 'com.facebook.android:facebook-android-sdk:7.0.0'なお、FacebookSDKの最新バージョンは以下から見つけられます。
※日本語のFirebaseドキュメントには、TwitterSDKも依存関係に入れるように書いてありますが、
firebase-ui-auth:6.2.0
から不要になったようです。それと、他言語の膨大な文字列リソースを読み込まないように、可能なら言語を指定しておきましょう。apkのサイズが肥大化してしまうのを防げます。
ただし英語だけは入ってしまうようです。app/build.gradledefaultConfig { ... resConfigs "ja" }Gradle Syncしておいて下さいね。
(2)ビルドしてみる
取り敢えずビルドしてみて下さい。
通らなかった!と言う人もいるでしょうね。
多分そういう方は、
minSDKVersoin
を20以下にしていませんか?
残念でしたね。これは20以下にしているアプリの宿命です。エラーメッセージの最後の方に、こんなふうに出ていると思います。
* What went wrong: Execution failed for task ':app:mergeDexDebug'. > A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade > com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: The number of method references in a .dex file cannot exceed 64K. Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.htmlはい、Andoridの非常に厳しい制限がここで登場します。
Androidでは、デフォルトでは1つのアプリに65535個を超えるメソッドや変数があってはならないのです。この数字に、ITエンジニアなら覚えはあるでしょうか。最近の人はないかなあ。
基本情報技術者試験を受けている人ならピンとこないと行けませんよ。プログラマー的な言い方をすれば、SHORT型なんだね、と。
もっと一般的?な言い方をすると、16bitを超えられないということになります。「今のご時世に16bitの制限があるとは」
と驚きたくもなりますが、Windowsのフルパス名も長らく255文字の制限(こちらは8bit^^;)があったことを考えると、そこの型のサイズを変えるのはとても大変なのでしょう。
じゃあ、どうするか?このままじゃ、アプリビルドできない?
何か機能を減らさないとダメなのか??
minSDKVersionを21以上に上げなきゃダメ?なんてことはありません。ちゃんと回避策はあります。
先ほど、「Androidでは、デフォルトでは1つのアプリに〜」と書きましたが、もう少し厳密に言うと、AndroidはJavaのクラスファイルを.dex
というファイルに書き出していて、その.dex
ファイルの1つには、64kの制限があるのです。
で、デフォルトでは、その.dex
ファイルを1つしか作ろうとしないために、64k超えたらビルドできなくなるわけです。(3)ようこそMultidexの世界へ(minSDKVersion<21限定)
ということで、複数の
dex
ファイルを作ってくれるように指定します。
app/build.gradle
に、multiDexEnabled true
とライブラリを追加します。app/build.gradleandroid { defaultConfig { ... minSdkVersion 19 targetSdkVersion 28 multiDexEnabled true } ... } dependencies{ .... // multidex implementation 'androidx.multidex:multidex:2.0.1' }
MyApp
クラスの基底クラスを、MultiDexApplication
にするMyApp.ktclass MyApp : MultiDexApplication()これでビルド、実行が出来るはずです。
(4)マニフェストファイルとリソースファイル
- マニフェストファイルにINTERNETパーミッションを追加していない場合は追加する
AndroidManifest.xml<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="jp.les.kasa.sample.mykotlinapp"> ... <uses-permission android:name="android.permission.INTERNET"/> <application ...
- Facebookログインをする場合、
/app/res/values/strings.xml
に次のように追加/app/res/values/strings.xml<string name="facebook_application_id" translatable="false">{アプリID}</string> <!-- Facebook Application ID, prefixed by 'fb'. Enables Chrome Custom tabs. --> <string name="facebook_login_protocol_scheme" translatable="false">fb{アプリID}</string>
{アプリID}
の部分を、FacebookのアプリIDに置き換えて下さい。※Firebaseのドキュメント(英語も)には、Twitterのクライアントシークレットなども記述するように記載がありますが、不要です。
(5)ログインメニューを作る
ログインの契機となる場所を作っていきます。
アプリの初回起動時でも良いですが、いったん、このアプリは、
- クラウドでデータを同期したいユーザーだけが、ログインをする
とします。
つまり、ローカルでデータを記録するだけならログインしなくてもアプリを使えるということにします。
RoomとFirestoreの共存になり大変ですが、まあ勉強なので。
それに、Firestoreを使い始めると、多分ユーザー数によりますがお金が掛かってきます。
なので、「クラウド保存は課金して使える機能にする」とか、そんなことも出来ますね。
その場合、Room⇔Firestoreのデータ変換も必要になるので、かえって大変かも知れませんが・・・ということで、アプリの右上に、ログインメニューを追加することにします。
Activityへのメニューの追加は、こちらでやりましたね。
Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(3)リスト表示編Android Studioで、[res]-[menu]を右クリックし、[New]-[Menu Resource File]とします
ファイル名は
main_menu.xml
とします。出来たファイルの中身を以下のようにします。
main_menu.xml<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item android:id="@+id/login" android:icon="@drawable/ic_person_24dp" android:title="@string/menu_label_login" app:showAsAction="ifRoom" /> </menu>アイコンは、[Drawable]-[New]-[Vector Asset]で作りました。
menu_label_login
は<string name="menu_label_login">ログイン</string>
です。
今後メニューが増えるかもしれないので、app:showAsAction
をifRoom
にしました。ifRoom
にしておくと、Toolbarに十分なスペースがあればアイコンが表示され、足りないとメニューアイコンでまとめられてポップアップでリストが出てくるようになります。(6)MainActivityにメニューを追加
MainActivity
にこのメニューを追加します。MainActivity.kt// メニュー追加 override fun onCreateOptionsMenu(menu: Menu?): Boolean { val inflater = menuInflater inflater.inflate(R.menu.main_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem?): Boolean { item?.let { return when (it.itemId) { R.id.login -> { val intent = Intent(this, SignInActivity::class.java) startActivityForResult(intent, REQUEST_CODE_SIGN_IN) true } else -> false } } return false }
REQUEST_CODE_SIGN_IN
にcompanion object
で他のリクエストコードと被らない
任意の数値を指定して下さい。(7)ログインページの追加
新しいActivityを作成します。
LoginActivity
などでいいでしょう。でも個人的に、LogInput
とかLogItem
とか同じスペルで始まるのが多いので、ここはSignInActivity
にしました。
ちなみに、一般的に"Sign Up"だと「アカウント作成」で、"Sgin in"だといわゆる「ログイン」として使われていますね。パッケージ名は悩んだのですが、いったんsignin
にしました。レイアウトはこんな感じです。
サンプルコードはこちらから
activity_signin.xml<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context="jp.les.kasa.sample.mykotlinapp.activity.signin.SignInActivity"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </com.google.android.material.appbar.AppBarLayout> <ScrollView android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" app:layout_behavior="@string/appbar_scrolling_view_behavior"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <ImageView android:layout_width="100dp" android:layout_height="100dp" android:layout_gravity="center_horizontal" android:src="@drawable/ic_cloud_upload_24dp" android:tint="@color/colorPrimary" /> <TextView android:id="@+id/descriptionSignIn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/text_sign_in_description" /> <Button android:id="@+id/buttonSignIn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="8dp" android:text="@string/label_sign_in" /> </LinearLayout> </ScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout>実際にログインをするページと言うよりは、ログインすることで出来ることなどの説明のページになります。
たとえば、この機能を有料にしたい場合は、ここで課金する機能を入れても良いでしょう。signin/SignInActivity.ktclass SignInActivity : BaseActivity() { companion object { const val SCREEN_NAME = "サインイン画面" } // 画面報告名 override val screenName: String get() = SCREEN_NAME override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_signin) setSupportActionBar(toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) buttonSignIn.setOnClickListener { // TODO FirebaseUI Auth呼び出し } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { onBackPressed() return true } } return super.onOptionsItemSelected(item) } }(8)FirebaseUI Authの呼び出し
いよいよFirebaseUI Authを呼び出します。
signin/SignInActivity.ktbuttonSignIn.setOnClickListener { analyticsUtil.sendSignInStartEvent() // Choose authentication providers val providers = arrayListOf( AuthUI.IdpConfig.EmailBuilder().build(), AuthUI.IdpConfig.GoogleBuilder().build(), AuthUI.IdpConfig.FacebookBuilder().setPermissions(listOf("email")).build(), AuthUI.IdpConfig.TwitterBuilder().build(), AuthUI.IdpConfig.GitHubBuilder().build() ) FirebaseCrashlytics.getInstance().log("FirebaseUI Auth called.") // Create and launch sign-in intent startActivityForResult( AuthUI.getInstance() .createSignInIntentBuilder() .setAvailableProviders(providers) .build(), REQUEST_CODE_AUTH ) }呼び出しは、これだけ。
REQUEST_CODE_AUTH
はcompanion object
に定義して下さいね。整数値ですよ。
val providers = arrayListOf
に、FirebaseコンソールでSign-in Methodに設定した認証方法を設定しています。
問い合わせやクラッシュに繋がりそうなのでレポートもいろいろ仕込みました。Facebookだけ
setPermissions(listOf("email"))
しているのは、何も指定しないとそれ以外のdefault
権限に定義されているユーザー情報も取ってしまうらしいので、このアプリでは不要なため取らないようにするためです。(確か、defaultで取れるように権限付けてくれている癖に、「不要なのに要求している」とFacebookからリジェクトされたと聞いたことがあります--;)
startActivityForResult
で、FierbaseUIを呼び出しています。
普通のActivity遷移と変わりませんね。startActivityForResult
ですから、onActivityResult
で結果を受け取ります。signin/SignInActivity.ktoverride fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_CODE_AUTH) { FirebaseCrashlytics.getInstance() .log("FirebaseUI Auth finished. result code = [$resultCode]") val response = IdpResponse.fromResultIntent(data) if (resultCode == Activity.RESULT_OK) { // Successfully signed in val user = FirebaseAuth.getInstance().currentUser analyticsUtil.sendSignInEvent() // TODO Roomのデータをコンバートしてアップロード // or Firestoreからデータをダウンロード } else response?.error?.errorCode?.let { errorCode -> analyticsUtil.sendSignInErrorEvent(errorCode) FirebaseCrashlytics.getInstance() .log("FirebaseUI Auth finished. error code = [$errorCode]") // Sign in failed. If response is null the user canceled the // sign-in flow using the back button. Otherwise check // response.getError().getErrorCode() and handle the error. // ... showError(errorCode) } } } @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) fun showError(errorCode: Int) { val messageId = when (errorCode) { EMAIL_MISMATCH_ERROR -> { // メールアドレス不一致 R.string.error_email_mismacth } ERROR_GENERIC_IDP_RECOVERABLE_ERROR, PROVIDER_ERROR -> { R.string.error_id_provider } ERROR_USER_DISABLED -> { R.string.error_user_disabled } NO_NETWORK -> { R.string.error_no_netowork } PLAY_SERVICES_UPDATE_CANCELLED -> { R.string.error_service_update_canceled } else -> { R.string.error_unknown } } val error = getString(R.string.label_error_code, errorCode) val message = "${getString(messageId)}\n\n$error" val dialog = ErrorDialog.Builder().message(message).create() dialog.show(supportFragmentManager, DIALOG_TAG_AUTH_ERROR) }あとでテストすることを考えて、
showError
には@VisibleForTesting
を付けています。アプリを起動して、いろいろな認証方法でログインしてみましょう。
なお、端末の言語設定が英語だと英語でUIが表示されます。
エミュレーターだと日本語にしてないこともあるかと思うので、ご注意を。メールログインだと、初めての登録とか、パスワード忘れたときのリセットリンクとか、全部やってくれます。なお、その場合のメールのテンプレートは、Firebaseのコンソールで[Authentication]の[Templates]タブで変更が出来ます。ただし変更できるのは「パスワードの再設定」だけ見たいです。スパム防止のためらしいですが・・・
Facebookは、Facebookアプリがインストールされてない場合は、ブラウザが起動してブラウザログインになります。
注意事項としては、Facebookは、「開発者アカウント」でログインする必要があることです。
というのも、アプリがまだ「開発」で登録してあるからです。リリースモードにするには、Facebookのレビューが必要になります。
逆にGithubは開発者アカウント「以外」でログインしないとダメなようです。
TwitterはどちらでもOKでした。また、Twitterアプリのインストールに関係なく、ブラウザログインでの認証がされます。※Twitter/Githubは、ブラウザからログイン済みで、アカウント認証しようとすると、AuthUIがエラーになってしまいます。エラーメッセージは何も出ませんが、コンソール見ると例外ログが出ています。
2020-05-16 03:35:29.515 22989-22989/jp.les.kasa.sample.mykotlinapp.debug E/AuthUI: A sign-in error occurred. com.firebase.ui.auth.data.model.UserCancellationException: Unknown error at com.firebase.ui.auth.data.remote.GenericIdpSignInHandler$1.onFailure(GenericIdpSignInHandler.java:112) at com.google.android.gms.tasks.zzl.run(Unknown Source:4) at android.os.Handler.handleCallback(Handler.java:873) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loop(Looper.java:193) at android.app.ActivityThread.main(ActivityThread.java:6669) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858) 2020-05-16 03:35:29.819 22989-22989/jp.les.kasa.sample.mykotlinapp.debug E/AuthUI: A sign-in error occurred. com.firebase.ui.auth.data.model.UserCancellationException: Unknown error at com.firebase.ui.auth.data.remote.GenericIdpSignInHandler$1.onFailure(GenericIdpSignInHandler.java:112) at com.google.android.gms.tasks.zzl.run(Unknown Source:4) at android.os.Handler.handleCallback(Handler.java:873) at android.os.Handler.dispatchMessage(Handler.java:99) at android.os.Looper.loop(Looper.java:193) at android.app.ActivityThread.main(ActivityThread.java:6669) at java.lang.reflect.Method.invoke(Native Method) at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:493) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:858)エミュレーターのせいだけかと思い、実機でやったら多分大丈夫でした。(試した限りでは・・・)
また、実機でテストする際、Facebookアプリやブラウザで通常アカウントをログアウトしておく必要があります。そうでないと、開発者以外のアカウントでのログインはまだ開発アプリの段階なので許可されません。
それと、サインアップすると識別子としてメールアドレスが記録されます。同じメールアドレスのユーザーは同じアカウントとみなさるため、例えば、同じGmailでFacebookやTwitterのアカウントを作っている場合、最初にGoogleログインしたあと、ログアウトして他の認証方法でログインしようとすると、「Googleアカウントでログイン済みです」のように表示され、別の方法ではログイン出来ませんのでご注意下さい。
同じメールアドレスで他の認証フローを試したい場合は、Firebase Authenticationのコンソールでユーザーを一度削除する必要があります。以下、色々ログインしてみた後のFirebase Authenticationのコンソールページの[Users]タブの表示内容です。
Githubのメールアドレスがなぜかこの時は提供されませんでした。
その後もう一度アカウントを作り直したら入っていたので、何かバグですかね・・・ログイン画面のカスタマイズ
FirebaseUIが表示する画面は、変更することが可能です。
たとえば、ロゴをセットできます。
アプリアイコンなどを出すのが良いでしょうね。今はドロイドくんになってしまいますが。
いずれアイコン画像の作り方の回も必要ですね。ロゴとテーマをセットする例
SignInActivity.ktstartActivityForResult( authUI.createSignInIntentBuilder() .setAvailableProviders(providers) .setLogo(R.mipmap.ic_launcher) // ロゴをセット .setTheme(R.style.SinUpTheme) // テーマを変更 .build(), REQUEST_CODE_AUTH )
SignUpTheme
はこんなので作ってみました。styles.xml<!-- ログイン画面用の別テーマ --> <style name="SinUpTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <item name="colorPrimary">@color/colorPrimarySignIn</item> <item name="colorPrimaryDark">@color/colorPrimaryDarkSignIn</item> <item name="colorAccent">@color/colorAccentSignIn</item> <item name="android:windowBackground">@color/colorBackgroundSinIn</item> </style>
windowBackground
が直接カラー指定だとビルドエラーになってしまうので、わざわざcolor
リソースも作りました。colors.xml<?xml version="1.0" encoding="utf-8"?> <resources> ... <color name="colorPrimarySignIn">#5C6BC0</color> <color name="colorPrimaryDarkSignIn">#303F9F</color> <color name="colorAccentSignIn">#FF6D00</color> <color name="colorBackgroundSinIn">#B2EBF2</color> </resources>もっとカスタムしたレイアウトを使うことも出来ます。
SignInActivity.ktval authLayout = AuthMethodPickerLayout.Builder(R.layout.layout_auth) .setEmailButtonId(R.id.email_button) .setFacebookButtonId(R.id.facebook_button) .setTwitterButtonId(R.id.twitter_button) .setGoogleButtonId(R.id.google_button) .build() // Create and launch sign-in intent startActivityForResult( authUI.createSignInIntentBuilder() .setAvailableProviders(providers) .setAuthMethodPickerLayout(authLayout) .build(), REQUEST_CODE_AUTH )
R.layout.layout_auth
に使いたいレイアウトを組んでおき、それをAuthMethodPickerLayout.Builder
に指定、各ボタンIDをauthUI側に連携させるためにセットする、という形になります。なお、setGithubButtonId
がないので、この場合はGithubログインを諦めるしか無さそうです。
(一応Issuesに登録してみました: https://github.com/firebase/FirebaseUI-Android/issues/1783)
setTosAndPrivacyPolicyUrls
で利用規約ページとプライバシーポリシーページをセットすると、自動的にページに表示してくれます。SignInActivity.ktstartActivityForResult( authUI.createSignInIntentBuilder() .setAvailableProviders(providers) .setLogo(R.mipmap.ic_launcher) .setTheme(R.style.SinUpTheme) .setTosAndPrivacyPolicyUrls( "https://qiitapedometersample.web.app/policy.html", "https://qiitapedometersample.web.app/policy.html" ) .build(),ロゴ、テーマ、ポリシーページを入れたサンプルです。
配色の悪さは気にしないで下さいwログアウトページ
ログイン中は、先ほどの画面をログアウトできるようにして行きます。
ここはActivityの中でレイアウトを切り替えるより、起動時にログイン中だったら、ログイン中のActivityを起動して自分は終了することにします。
(※このアプローチだけが正解ということはありません。アプリの設計思想に依ります。例えば、Fragmentを分けておいて、ログイン中かどうかで出し分けるというのもアリです。ただ、Fragmentのテストが結構厳しいので、Activityにしておきたい感じです^^;)1.ログイン中判定
FirebaseAuth.getInstance().currentUser
がnullでないかで判定できます。
なので、画面を起動する場合、こうなります。SignInActivity.ktoverride fun onResume() { super.onResume() // ログイン中だったら画面を変える val user = FirebaseAuth.getInstance().currentUser if (user != null) { startActivity(Intent(this, SignOutActivity::class.java)) finish() } }やるのは
onResume
がいいです。というのも、ログインが成功して戻ってきたときに変えたいからです。
とはいえ、ログイン成功したらこのページは終了しても良いので、onActivityResult
でfinishしてもいいかもしれませんね。SignInActivity.ktoverride fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_CODE_AUTH) { ... if (resultCode == Activity.RESULT_OK) { Log.d("AUTH", "Auth Completed.") // Successfully signed in analyticsUtil.sendSignInEvent() finish() }取り敢えず今は、ログイン→ログアウト→ログインが直ぐ出来るように、
onActivityResult
でのfinish
はしないでおきます。2.ログアウト画面
ログアウト画面はこんなデザインにしました。
レイアウトxmlは自由に組んでみてください。
サンプルレイアウトxml
activity_signout.xml<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> <variable name="userData" type="jp.les.kasa.sample.mykotlinapp.data.LoginUserData" /> </data> <androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context="jp.les.kasa.sample.mykotlinapp.activity.signin.SignOutActivity"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <androidx.appcompat.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </com.google.android.material.appbar.AppBarLayout> <ScrollView android:id="@+id/signOutScroll" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="16dp" app:layout_behavior="@string/appbar_scrolling_view_behavior"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/labelSignIn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="16dp" android:text="@string/text_sign_in_now" android:textSize="20dp" /> <ImageView android:id="@+id/imageCloudDone" android:layout_width="100dp" android:layout_height="100dp" android:layout_gravity="center_horizontal" android:src="@drawable/ic_cloud_done_24dp" android:tint="@color/colorPrimary" /> <TextView android:id="@+id/textUserName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:text="@{userData.userName}" android:textSize="14dp" tools:text="user_display_name" /> <TextView android:id="@+id/textUserEmail" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:text="@{userData.emailAddress}" android:textSize="14dp" tools:text="hoge@abc.xyz.com" /> <TextView android:id="@+id/descriptionSignIn" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/text_sign_out_description" /> <Button android:id="@+id/buttonSignOut" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="8dp" android:text="@string/label_sign_out" /> <Button android:id="@+id/buttonConvert" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="8dp" android:text="@string/label_convert_to_local" /> <Button android:id="@+id/buttonAccountDelete" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:layout_marginTop="8dp" android:layout_marginBottom="8dp" android:text="@string/label_account_delete" android:textColor="@color/colorAccent" /> </LinearLayout> </ScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout> </layout>(1)ユーザー情報の表示
ユーザー表示名と、メールアドレスを表示し、自分がどんなアカウントでログイン中なのか分かるようにします。
SignOutActivity.ktlateinit var binding: ActivitySignoutBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = DataBindingUtil.setContentView(this, R.layout.activity_signout) setSupportActionBar(toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) val user = FirebaseAuth.getInstance().currentUser val userData = LoginUserData(user?.displayName ?: getString(R.string.label_you), user?.email ?: getString(R.string.label_no_email)) binding.userData = userData }Databindingを使っています。
displayName
も
LogUserData
はこんな感じです。LogUserData.kt/** * ログイン中のユーザー情報を表示するための情報 */ data class LoginUserData(val userName: String, val emailAddress: String)(2)サインアウト
サインアウト処理は、以下のように行います。
SignOutActivity.kt// サインアウトボタン buttonSignOut.setOnClickListener { analyticsUtil.sendSignOutEvent() authUI.signOut(this) .addOnCompleteListener { Log.d("AUTH", "User logout completed.") // サインイン画面に戻る startActivity(Intent(this, SignInActivity::class.java)) finish() } }
analyticsUtil.sendSignOutEvent
でサインアウト完了をアナリティクスに送っていますが、実は、ログインは定義済みイベントFirebaseAnalytics.Event.LOGIN
があるのですが、ログアウトがありません。なのでカスタムイベントとして送っています。AnalyticsUtil.kt/** * サインイン完了イベント送信 */ fun sendSignInEvent() { firebaseAnalytics.logEvent(FirebaseAnalytics.Event.LOGIN, null) } /** * サインアウト完了イベント送信 */ fun sendSignOutEvent() { firebaseAnalytics.logEvent("logout", null) }(3)アカウント削除
アカウント削除も出来ます。
SignOutActivity.ktprivate fun doDeleteAccount() { authUI.delete(this) .addOnCompleteListener { Log.d("AUTH", "Account delete completed.") analyticsUtil.sendDeleteAccountEvent() // サインイン画面に戻る startActivity(Intent(this, SignInActivity::class.java)) finish() } }これをボタンのクリックイベントで直ぐに呼ぶのはやめておきましょう。アカウントの削除は致命的な動作なので、しつこいくらい確認ダイアログを出した方が良いでしょうね。
ってことで、このプロジェクトでは、
- ローカルにデータ変換した?
- [はい] 削除決行
- ローカルにデータ変換してないとデータ復元できないよ?
- [はい] 削除決行
みたいな流れにします。
ConfirmDialog
は、以前作成して使ってなかったクラスですが、以下のようなものです。
ConfirmDialogクラス
ConfirmDialog.kt/** * 確認メッセージを表示するダイアログ<br> * [YES/NO]ボタンを表示します * 2019/08/30 **/ class ConfirmDialog : DialogFragment(), DialogInterface.OnClickListener { interface ConfirmEventListener { /** * 確認ダイアログのコールバック<br> * @param which : AlertDialogの押されたボタン(POSITIVE or NEGATIVE) * @param bundle : data()でセットしたBundleデータ * @param requestCode : targetFragmentと併せて指定したrequestCode */ fun onConfirmResult(which: Int, bundle: Bundle?, requestCode: Int) } private val analyticsUtil: AnalyticsUtil by inject() class Builder() { private var message: String? = null private var messageResId: Int = 0 private var target: Fragment? = null private var requestCode: Int = 0 private var data: Bundle? = null fun message(message: String): Builder { this.message = message return this } fun message(resId: Int): Builder { this.messageResId = resId return this } fun target(fragment: Fragment): Builder { this.target = fragment return this } /** * only for targetFragment */ fun requestCode(requestCode: Int): Builder { this.requestCode = requestCode return this } fun data(bundle: Bundle): Builder { this.data = bundle return this } fun create(): ConfirmDialog { val d = ConfirmDialog() d.arguments = Bundle().apply { if (message != null) { putString(KEY_MESSAGE, message) } else { putInt(KEY_RESOURCE_ID, messageResId) } if (data != null) { putBundle(KEY_DATA, data) } } if (target != null) { d.setTargetFragment(target, requestCode) } return d } } companion object { const val KEY_MESSAGE = "message" const val KEY_RESOURCE_ID = "res_id" const val KEY_DATA = "data" const val SCREEN_NAME = "確認ダイアログ" } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { // AlertDialogで作成する val builder = AlertDialog.Builder(requireContext()) // メッセージの決定 val message = when { arguments!!.containsKey(KEY_MESSAGE) -> arguments!!.getString(KEY_MESSAGE) else -> requireContext().getString( arguments!!.getInt(KEY_RESOURCE_ID) ) } // AlertDialogのセットアップ builder.setMessage(message) .setTitle(R.string.confirm) .setIcon(android.R.drawable.ic_dialog_info) .setNegativeButton(R.string.label_no, this) .setPositiveButton(R.string.label_yes, this) return builder.create() } override fun onResume() { super.onResume() activity?.let { analyticsUtil.sendScreenName(it, SCREEN_NAME) } } override fun onClick(dialog: DialogInterface?, which: Int) { FirebaseCrashlytics.getInstance().log("ConfirmDialog selected:$which") val data = arguments!!.getBundle(KEY_DATA) if (targetFragment is ConfirmEventListener) { val listener = targetFragment as ConfirmEventListener listener.onConfirmResult(which, data, targetRequestCode) return } else if (activity is ConfirmEventListener) { val listener = activity as ConfirmEventListener listener.onConfirmResult(which, data, targetRequestCode) return } Log.e( "ConfirmDialog", "Target Fragment or Activity should implement ConfirmEventListener!!" ) }
SignOutActivity
にConfirmDialog.ConfirmEventListener
を実装させ、SignOutActivity.ktclass SignOutActivity : BaseActivity(), ConfirmDialog.ConfirmEventListener {
onConfirmResult
を実装します。SignOutActivity.ktoverride fun onConfirmResult(which: Int, bundle: Bundle?, requestCode: Int) { when (bundle?.get("tag")) { TAG_CONFIRM_1 -> { if (which == DialogInterface.BUTTON_POSITIVE) { // データ削除した、なので削除決行 doDeleteAccount() } else { // データ削除しなくてよいかもう一度確認 val dialog = ConfirmDialog.Builder().data(Bundle().apply { putString("tag", TAG_CONFIRM_2) }) .message(R.string.confirm_account_delete_2) .create() dialog.show(supportFragmentManager, TAG_CONFIRM_2) } } TAG_CONFIRM_2 -> { if (which == DialogInterface.BUTTON_POSITIVE) { // アカウント削除してよい、なので削除決行 doDeleteAccount() } else { // いいえなので何もしない } } } }最後に、アカウント削除ボタンのイベントリスナーの処理を書きます。
SignOutActivity.kt// アカウント削除ボタン buttonAccountDelete.setOnClickListener { analyticsUtil.sendButtonEvent("delete_account") // 確認フローを開始する val dialog = ConfirmDialog.Builder().data(Bundle().apply { putString("tag", TAG_CONFIRM_1) }) .message(R.string.confirm_account_delete_1) .create() dialog.show(supportFragmentManager, TAG_CONFIRM_1) }「ローカルデータに変更する」ボタンは、Firestoreのデータ形式をRoomのデータに戻すことを考えています。
Firestoreのデータは、実はNonSQLなので、SQLiteなRoomのデータ構造とは異なってきます。なのでコンバートが必要なんですね。ただ、実はFirebase Authenticationには匿名ログインというのもあります。これを使って、匿名でFirestoreを使って貰うことは可能だと思いますので、取り入れてみるのも良いかも知れません。そうすると、Room用のデータ変換なども一切不要になります。
この記事用のプロジェクトでは、Roomのコードも残しておきたいのとFirestoreへの課金が怖いので(笑)、ローカルで使える方法も残しておきます。
データのコンバート方法については、Firestoreに対応してから入れますので、いまは空っぽです。
SignOutActivity.kt// ローカルデータに変更ボタン buttonConvert.setOnClickListener { analyticsUtil.sendButtonEvent("convert_to_local_data") // TODO FirestoreのデータをRoomに入れる }以上で、サインインとサインアウトが出来るようになりました。
今は特に意味が無いですが、Firestoreなどを使っていくと意義が出てくると思います。アプリのリリースをする前に必要な準備
アプリを正式にリリースする際には、ちょっとした注意点があるので気をつけて下さい。
Firabseコンソールのリリースビルド用情報への書き変え
- パッケージ名や証明書のSHA1情報をリリースビルド用のものに置き換える必要があります。
- リリースにGoogle Playアプリ署名を使う場合、自分で作成した証明書の情報では無く、実際に署名に使われた証明書のSHA1が必要になります。Fiebaseコンソールのプロジェクト設定にセットした署名情報をそちらで置き換える必要があります。Google Playアプリ署名で使われた証明書の情報は、各種フィンガープリントがGoogle Playコンソールで確認できます。(一度apkをアップロードすることが必要です。クローズドアルファ版のリリースの作成などを利用しましょう。)
Facebookログインがある場合
- Firebaseコンソールへの登録と同様、パッケージ名、証明書のSHA1ハッシュの書き換えが必要です。
- Facebookへの審査提出が必要となります。Facebook側が、こちらのアプリが不要な個人情報を取得していないか、レビューします。そのため、こちらの承認が通るまで、アプリのリリースは出来ません。なお、2020/05/16現在、新型コロナ対応のため個人開発者の承認を止めているとアナウンスされています。個人で上げようと考えている方は、しばらく待つか、Facebookログインは後日対応にするなどの対策が必要そうです。
テスト
サインインとサインアウトはさすがに自動テストするのは怖いので、今回作ったレイアウトが正しいか程度のテストにしておきます。
ただ、気をつけないと行けないのは、FirebaseAuth.getInstance().currentUser
が返す値です。
これにより画面が自動で遷移してしまうので、テスト中にはこの値に左右されないようにする必要があります。ということで、DI出来るようにして、Koinモジュールを差し替えるという以前やったのと同じことをやります。
1.DI出来るようにする
FirebaseAuth.getInstance().currentUser
をDIできるようにしていきます。(1)FirebaseAuthのインスタンスを得るためのクラスを作る
AuthProvider
クラスをこんな感じで作りました。AuthProvider.ktimport android.app.Application import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser import jp.les.kasa.sample.mykotlinapp.R import jp.les.kasa.sample.mykotlinapp.data.LoginUserData /** * FirebaseAuthのInstance取得を提供するプロバイダ用の抽象クラス */ abstract class AuthProviderI(app: Application) { abstract val user: FirebaseUser? val defaultUserName: String by lazy { app.getString(R.string.label_you) } val defaultEmail: String by lazy { app.getString(R.string.label_no_email) } abstract val userData: LoginUserData } /** * FirebaseAuthのInstance取得を提供するプロバイダ用のアプリで実際に使うクラス */ class AuthProvider(app: Application) : AuthProviderI(app) { override val user: FirebaseUser? get() { return FirebaseAuth.getInstance().currentUser } override val userData: LoginUserData get() { return LoginUserData( user?.displayName ?: defaultUserName, user?.email ?: defaultEmail ) } }
override val user = FirebaseAuth.getInstance().currentUser
とすると、最初に代入された値のままになるのでやっちゃダメですよこれをKoinモジュールに登録します。
// FirebaseService val firebaseModule = module { single { AnalyticsUtil(androidApplication()) } single { AuthProvider(androidApplication()) as AuthProviderI } // 追加 }
FirebaseAuth.getInstance()
していたところを、AuthProviderI
を使うように変更します。SignInActivity.ktprivate val authProvider: AuthProviderI by inject() ... override fun onResume() { super.onResume() // ログイン中だったら画面を変える // val user = FirebaseAuth.getInstance().currentUser // if (user != null) { if (authProvider.user != null) { startActivity(Intent(this, SignOutActivity::class.java)) finish() } }
SignOutActivity
はLoginUserData
を作っていましたね。それをAuthProvider
のプロパティアクセスに変更します。SignOutActivity.ktbinding.userData = authProvider.userData変更漏れが無いかどうかは、
FirebaseAuth.getInstance()
でプロジェクト内検索をすると良いです。
ショートカットはMacだとShift+Command+f
かな?(2)テスト用のKoinモジュールの変更
テスト用のモック化モジュールで
AuthProvider
を変更します。
なお、Mockitoが使えればもっと簡単にできるのですが、どうやっても上手くいかなかったので、苦肉の策になっています。MockModules.kt// FirebaseAuthを提供するプロバイダのテスト用 class TestAuthProvider(app: Application) : AuthProviderI(app) { override val user: FirebaseUser? get() { return if (mockFirebaseUser) MockFirebaseUser() else null } override val userData: LoginUserData get() { return LoginUserData( "ユーザー名", "foo@bar.com" ) } var mockFirebaseUser = false } // FirebaseUserモック class MockFirebaseUser : FirebaseUser() { override fun zzg(): String { TODO("Not yet implemented") } override fun zze(): zzff { TODO("Not yet implemented") } override fun getEmail(): String? { TODO("Not yet implemented") } override fun zzc(): FirebaseApp { TODO("Not yet implemented") } override fun zza(): MutableList<String> { TODO("Not yet implemented") } override fun zza(p0: MutableList<out UserInfo>): FirebaseUser { TODO("Not yet implemented") } override fun zza(p0: zzff) { TODO("Not yet implemented") } override fun getProviderData(): MutableList<out UserInfo> { TODO("Not yet implemented") } override fun writeToParcel(p0: Parcel?, p1: Int) { TODO("Not yet implemented") } override fun getMetadata(): FirebaseUserMetadata? { TODO("Not yet implemented") } override fun getMultiFactor(): MultiFactor { TODO("Not yet implemented") } override fun isAnonymous(): Boolean { TODO("Not yet implemented") } override fun getPhoneNumber(): String? { TODO("Not yet implemented") } override fun getUid(): String { TODO("Not yet implemented") } override fun isEmailVerified(): Boolean { TODO("Not yet implemented") } override fun zzf(): String { TODO("Not yet implemented") } override fun zzd(): String? { TODO("Not yet implemented") } override fun zzb(): FirebaseUser { TODO("Not yet implemented") } override fun zzb(p0: MutableList<MultiFactorInfo>?) { TODO("Not yet implemented") } override fun getDisplayName(): String? { TODO("Not yet implemented") } override fun getPhotoUrl(): Uri? { TODO("Not yet implemented") } override fun getProviderId(): String { TODO("Not yet implemented") } }外から
mockFirebaseUser
を無理矢理変えられるようにして、user
をnullか実体を返すか処理を分けています。これを
testMockModule
に追加します。MockModules.kt// テスト用にモックするモジュール val testMockModule = module { .... single(override = true) { TestAuthProvider(androidApplication()) as AuthProviderI } }これで準備は完了しました。
2.UnitTest(androidTest)
今回から、Robolectric版を試すのは少し時間がかかってしんどいので、割愛します。すみません。気が向いたらGithubのリポジトリにはアップしておきます・・・
Robolectric版を書いてみる方は、今回の内容では、ダイアログ周りのテストがandroidTest版とRobolectric版では異なってきますので、その辺りに注意してみてください。
(1)SignInActivityのテスト
まずは初期化までのコード。
とくに真新しいところはない・・・はずですね。SignInActivityTest.ktimport android.app.Instrumentation import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import jp.les.kasa.sample.mykotlinapp.R import jp.les.kasa.sample.mykotlinapp.di.TestAuthProvider import jp.les.kasa.sample.mykotlinapp.di.testMockModule import jp.les.kasa.sample.mykotlinapp.utils.AuthProviderI import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koin.core.context.loadKoinModules import org.koin.core.inject import org.koin.test.AutoCloseKoinTest @RunWith(AndroidJUnit4::class) class SignInActivityTest : AutoCloseKoinTest() { @get:Rule val activityRule = ActivityTestRule(SignInActivity::class.java, false, false) lateinit var activity: SignInActivity private val authProvider: AuthProviderI by inject() @Before fun setUp() { loadKoinModules(testMockModule) } }通常起動時のレイアウトのテストです。
SignInActivityTest.kt@Test fun layout() { activity = activityRule.launchActivity(null) // クラウドアイコン onView(withId(R.id.imageCloudUpload)) // .check(matches(withDrawable(R.drawable.ic_cloud_upload_24dp))) // Tintカラー付けていると使えない .check(matches(ViewMatchers.isDisplayed())) // 文言 onView(withText(R.string.text_sign_in_description)) .check(matches(ViewMatchers.isDisplayed())) // ログインボタン onView(withText(R.string.label_sign_in)) .check(matches(ViewMatchers.isDisplayed())) }コメントにもありますが、
withDrawable
がTintカラーを付けているとfalseになってしまうため、そのチェックをやむを得ず外しています。これも試行錯誤したのですが、解決できませんでした。ログイン済みだった時用の、
SignOutActivity
が起動しているかのテストはこうなります。SignInActivityTest.kt/** * ログイン中の場合にサインアウト画面がでるかのテスト */ @Test fun moveToSignOut() { // モックを作成 (authProvider as TestAuthProvider).mockFirebaseUser = true // ResultActivityの起動を監視 val monitor = Instrumentation.ActivityMonitor( SignOutActivity::class.java.canonicalName, null, false ) InstrumentationRegistry.getInstrumentation().addMonitor(monitor) activity = activityRule.launchActivity(null) assertThat(activity.isFinishing).isTrue() // ResultActivityが起動したか確認 InstrumentationRegistry.getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L) assertThat(monitor.hits).isEqualTo(1) }それとエラー表示のテストですね。
SignInActivityTest.kt@Test fun showError_EMAIL_MISMATCH_ERROR() { activity = activityRule.launchActivity(null) activity.showError(ErrorCodes.EMAIL_MISMATCH_ERROR) InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(withText(startsWith(getString(R.string.error_email_mismacth)))) .check(matches(isDisplayed())) onView( withText( endsWith( getString( R.string.label_error_code, ErrorCodes.EMAIL_MISMATCH_ERROR ) ) ) ) .check(matches(isDisplayed())) onView(withText(R.string.close)) .check(matches(isDisplayed())) .perform(click()) onView(withText(startsWith(getString(R.string.error_email_mismacth)))) .check(doesNotExist()) }FirebaseUIを起動したくないので、resultDataを指定してActivity起動の戻りをモックする方法は使いません。そのために
showError
を関数に出していました^^;
後はエラーコード毎にチェックする関数を増やせば良いですね。
JUnit5のParameterizedTestが出来ると楽なんですが・・・(2)SignOutActivityのテスト
同じように、初期起動時のレイアウトのチェックと、各ボタンを押したときのダイアログのチェックなどを入れていけば良いですね。
ちょっと自力で頑張って書いてみて下さい。
サンプルコードはこちらから。
SignOutActivityTest.ktpackage jp.les.kasa.sample.mykotlinapp.activity.signin import android.app.Instrumentation import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.* import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ActivityTestRule import jp.les.kasa.sample.mykotlinapp.R import jp.les.kasa.sample.mykotlinapp.di.TestAuthProvider import jp.les.kasa.sample.mykotlinapp.di.testMockModule import jp.les.kasa.sample.mykotlinapp.utils.AuthProviderI import org.assertj.core.api.Assertions import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koin.core.context.loadKoinModules import org.koin.core.inject import org.koin.test.AutoCloseKoinTest @RunWith(AndroidJUnit4::class) class SignOutActivityTest : AutoCloseKoinTest() { @get:Rule val activityRule = ActivityTestRule(SignOutActivity::class.java, false, false) lateinit var activity: SignOutActivity private val authProvider: AuthProviderI by inject() @Before fun setUp() { loadKoinModules(testMockModule) } /** * 起動直後の表示のテスト<br> */ @Test fun layout() { activity = activityRule.launchActivity(null) // サインイン中 onView(withText(R.string.text_sign_in_now)) .check(matches(isDisplayed())) // クラウドアイコン onView(withId(R.id.imageCloudDone)) // .check(matches(withDrawable(R.drawable.ic_cloud_upload_24dp))) // Tintカラー付けていると使えない .check(matches(isDisplayed())) // ユーザー名 onView(withText("ユーザー名")).check(matches(isDisplayed())) // メールアドレス onView(withText("foo@bar.com")).check(matches(isDisplayed())) // 文言 onView(withText(R.string.text_sign_out_description)) .check(matches(isDisplayed())) // ログアウトボタン onView(withText(R.string.label_sign_out)) .check(matches(isDisplayed())) // ローカルデータ変換ボタン onView(withText(R.string.label_convert_to_local)) .check(matches(isDisplayed())) // アカウント削除ボタン onView(withText(R.string.label_account_delete)) .check(matches(isDisplayed())) } @Test fun signOut() { // モックを作成 (authProvider as TestAuthProvider).mockFirebaseUser = true activity = activityRule.launchActivity(null) // ResultActivityの起動を監視 val monitor = Instrumentation.ActivityMonitor( SignInActivity::class.java.canonicalName, null, false ) InstrumentationRegistry.getInstrumentation().addMonitor(monitor) // ログアウトボタン onView(withText(R.string.label_sign_out)) .perform(click()) Assertions.assertThat(activity.isFinishing).isTrue() // ResultActivityが起動したか確認 InstrumentationRegistry.getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L) Assertions.assertThat(monitor.hits).isEqualTo(1) } @Test fun deleteAccount_cancel() { activity = activityRule.launchActivity(null) onView(withId(R.id.signOutScroll)).perform(swipeUp()) // アカウント削除ボタン onView(withId(R.id.buttonAccountDelete)) .perform(scrollTo(), click()) onView(withText(R.string.confirm_account_delete_1)) .check(matches(isDisplayed())) onView(withText(R.string.label_no)) .check(matches(isDisplayed())) .perform(click()) onView(withText(R.string.confirm_account_delete_1)) .check(doesNotExist()) onView(withText(R.string.confirm_account_delete_2)) .check(matches(isDisplayed())) onView(withText(R.string.label_no)) .check(matches(isDisplayed())) .perform(click()) } }3.CIでのテスト
Github Actionsで、なぜか
SignOutActivity
のsignOut
テストが必ず失敗しました。手元のローカルマシンでは必ず成功するのにです。
サインアウトボタンを押すと、実際のauthUI.signOut
が呼ばれているので、これかなあと思い、AuthUI
もDIしてみました。AuthProvider.kt/** * FirebaseAuthのInstance取得を提供するプロバイダ用の抽象クラス */ abstract class AuthProviderI(app: Application) { .... abstract fun createSignInIntent(context: Context): Intent abstract fun signOut(context: Context): Task<Void?> abstract fun delete(context: Context): Task<Void?> } /** * FirebaseAuthのInstance取得を提供するプロバイダ用のアプリで実際に使うクラス */ class AuthProvider(app: Application) : AuthProviderI(app) { .... private val authUI = AuthUI.getInstance() override fun createSignInIntent(context: Context): Intent { // Choose authentication providers val providers = arrayListOf( AuthUI.IdpConfig.EmailBuilder().build(), AuthUI.IdpConfig.GoogleBuilder().build(), AuthUI.IdpConfig.FacebookBuilder().setPermissions(listOf("email")).build(), AuthUI.IdpConfig.TwitterBuilder().build(), AuthUI.IdpConfig.GitHubBuilder().build() ) return authUI.createSignInIntentBuilder() .setAvailableProviders(providers) .setLogo(R.mipmap.ic_launcher) .setTheme(R.style.SinUpTheme) .setTosAndPrivacyPolicyUrls( "https://qiitapedometersample.web.app/policy.html", "https://qiitapedometersample.web.app/policy.html" ) .build() } override fun signOut(context: Context): Task<Void?> { return authUI.signOut(context) } override fun delete(context: Context): Task<Void?> { return authUI.delete(context) } }テスト用のモックはこうしました。
MockModules.kt// FirebaseAuthを提供するプロバイダのテスト用 class TestAuthProvider(app: Application) : AuthProviderI(app) { ... override fun createSignInIntent(context: Context): Intent { return Intent(context, MockAuthUIActivity::class.java) } override fun signOut(context: Context): Task<Void?> { return Tasks.forResult(null) } override fun delete(context: Context): Task<Void?> { return Tasks.forResult(null) } } class MockAuthUIActivity : AppCompatActivity()
debug
ビルドでMockAuthUIActivity
が起動できるように、app/src/debug
にマニフェストファイルを作って置きました。app/src/debug/AndroidManifest.xml<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="jp.les.kasa.sample.mykotlinapp"> <application> <activity android:name="jp.les.kasa.sample.mykotlinapp.di.MockAuthUIActivity" /> </application> </manifest>
MockAuthUIActivity
がapp/src/debug/java/...
下にないので、このファイルをAndroid Studioで開くと赤くなってしまいますが、無視して大丈夫です。
SignInActivity
でサインインボタンを押したときに、このモック画面が開くかのテストを一応追加しました。SignInActivityTest.kt@Test fun signIn() { activity = activityRule.launchActivity(null) // ResultActivityの起動を監視 val monitor = Instrumentation.ActivityMonitor( MockAuthUIActivity::class.java.canonicalName, null, false ) InstrumentationRegistry.getInstrumentation().addMonitor(monitor) onView(withText(R.string.label_sign_in)).perform(click()) // ResultActivityが起動したか確認 InstrumentationRegistry.getInstrumentation().waitForMonitorWithTimeout(monitor, 1000L) assertThat(monitor.hits).isEqualTo(1) }あとは、
AuthUI.getInstance()
していたところをauthProvider
経由に変えれば、ビルドが通過し、アプリが動作するはずです。で、Github Actionsの結果は・・・
通るようになりました\(^_^)/
やはりAuthuUIの呼び出しが悪さをしていたようです。
(MainActivityTestIも時々失敗しますが、これはまあ目を瞑ります。通ることもあるので・・・)ところで、Github Actionsは最近タイムアウトすることがあるので(みんな使いすぎ?)、privateリポジトリで使っている方はあっという間に無料枠を使い切ります(返してくれないんですねーT_T)。ご注意を〜
(publicなリポジトリは無制限です。Flutterのプロジェクトがprivateなのですが、やらかしてくれましたorz)なんにも設定していないと、360分タイムアウトまで待つらしいので、次のように設定しておくと良いみたいです。
github/workflow/android.yamljobs: build: runs-on: macOS-latest timeout-minutes: 30まとめ
エミュレーターでの挙動がおかしくて、かなり悩んで時間を費やしてしまいました。
素直に実機でテストすれば良かったです。エミュレーターで出来ないとなると、Facebookアプリをインストールしていない端末を用意するのが少し面倒かも知れませんね。(プリインされていてアンインストールできない端末が多い)ここまでのプロジェクトコードは、以下のリポジトリにアップしてあります。
https://github.com/le-kamba/qiita_pedometer/tree/feature/qiita_12予告
いよいよ、Firestoreを使ってクラウド保存するのをやってみます。
参考サイトなど
Facebookログインの必要な設定について参考にしました。
https://www.techotopia.com/index.php/Facebook_Login_Authentication_using_FirebaseUI_Auth言語リソースの特定方法について参考にしました。
https://github.com/firebase/FirebaseUI-Android/blob/master/auth/README.md#configuration
- 投稿日:2020-05-16T18:40:48+09:00
【Flutter】超オススメの「state_notifier + freezed」を使って、カウンターアプリをつくるよ
はじめに
いま、Flutter界隈では、「state_notifier + freezed」を使って開発するのがアツい! と、評判です。
Flutter state_notifierいい感じなので使ったほうが良いですよ
https://qiita.com/_masaokb/items/fe77495db0aeba226d2a
「state_notifier」は、providerと組み合わせて使い、Widgetから「状態」と「ロジック」を簡単に分離し&通知することができるライブラリです。Widgetの状態管理を楽にしてくれたり、無駄なリビルドを抑制したりしてくれます。
「freezed」は、State(状態)を「データを保持するだけのクラス」として変更不可(イミュータブル)なクラスとして表現することができます。
Flutter界隈のエンジニアの方々もオススメされているので、「流行ってるみたいだし、めっちゃよさげだし、私も乗り遅れないようにしないと」と思いました。
次にやるFlutter案件
— 文字数カウントメモ開発者? takashi (@cloverkizuna) May 15, 2020
・state_notifier
・freezed
を使うことにした?state_notifierとfreezedパッケージで何かアプリ作ってみようかな!
— Shogo?宮崎 | 対局時計さん⚫️⏰⚪️ (@shogo0525) May 17, 2020
StateNotifierを使ったFlutterのアプリ設計 https://t.co/bDrDGkVopvFlutter state notifier と freezed の おかけで 死ぬほど楽
— 奥村晋太郎 (@120921Shin) May 12, 2020
どっかのタイミングでこの知見は共有したいこの前リリースしたアプリがstate_notifierとfreezedで作ってるんだけど、書いてて結構やりやすいと思った。
— としのぶ@個人開発中? (@Toshinobu724) May 19, 2020
けど、あの書き方であってるのかがわからない
みんなどうやってstate_notifier使ってるんだろstate_notifierパッケージがとても良いので、それベースに変えた( ´・‿・`)基本は同じだけど( ´・‿・`)
— mono ? @自宅 ? (@_mono) March 10, 2020
- StateNotifierを基本的に常時利用
- StateNotifierが肥大化したら適宜、子StateNotifier的なものに分割して移譲
- stateクラスは基本的にfreezed利用
- 基本的な扱い方は変わらず https://t.co/e1C56Es4mp私も、実際に使ってみて、めちゃくちゃ良かった ですし、オススメ です。
そこで本記事では、「state_notifier + freezed」について、カウンターアプリを作りながら説明します。
本記事のコードについて、以下のGitHubリポジトリに公開しています。
https://github.com/karamage/flutter_state_notifier_freezed_samplestate_notifier について
https://pub.dev/packages/state_notifier
state_notifier は、providerでの状態管理 を、より簡単に、楽にするライブラリです。
Flutterで、状態管理のパターンとして、以下のものがよく使われています。
- setState(StatefulWidget)
- Redux
- BLoC(Stream + InheritedWidget/Scoped Model)
- provider(ChangeNotifier)
現時点で、Flutterの状態管理において ベストプラクティスは、providerを使うこと だと思います。
(※個人の感想です。Flutterは進化が早いし、流行り廃りが激しい分野なので、これからどうなるかはワカランです。)
Google公式においても、providerの使用を推奨しています。
(Pragmatic State Management in Flutter (Google I/O'19))state_notifierは、providerをより使いやすくコードをスッキリと楽に書けるようにしてくれるパッケージです。
従来のproviderパターンにおいて、ChangeNotifierを使ってゴリゴリとコードを書いていたのですが、コードが少々冗長になり、Widget階層が深くなりがちな問題があります。
state_notifierベースでproviderのコードを書くと、
ChangeNotifierを使用した場合よりコードがスッキリし、
コードを書いていて気持ちが良いです。私は、これまでChangeNotifierベースでコードを書いていたのですが、
これからはstate_notifierベースに書き換えていこうと思います。freezed について
https://pub.dev/packages/freezed
freezedパッケージは、イミュータブルなデータモデルを作成するのに使います。
Stateは、データを保持するだけのクラスにしたいので、 変更不可(イミュータブル/freezed)なクラスにします。
state_notifierでは、State(状態)をイミュータブルなデータモデルとして扱います。
state_notifierとfreezedは相性が良く、セットで使うのが推奨されます。
freezedを使うと、StateにcopyWith(clone)メソッドが自動的に生えるので、便利です。state_notifier と freezed をパッケージインストール
pubspec.yaml を以下のように書きます。
pubspec.yamldependencies: flutter: sdk: flutter provider: ^4.1.0-dev state_notifier: ^0.4.0 flutter_state_notifier: ^0.3.0 freezed_annotation: dev_dependencies: flutter_test: sdk: flutter json_serializable: build_runner: freezed:json_serializableは、今回のアプリではJSONを扱わないので入れなくても良いのですが、JSONの扱う際にfreezedとセットでいれとくと便利です。
ターミナルで以下のコマンドを叩くと、state_notifier と freezed パッケージのインストールが完了します。
flutter pub getカウンターアプリを実装する
ここで、flutter createした際のボイラーテンプレートのカウンターアプリを、「state_notifier + freezed」を使用して実装してみます。
CounterStateとCounterStateNotifierを定義する
新規ファイル「counter_state.dart」 を作成し、以下のコードを記述してください。
counter_state.dartimport 'package:flutter/foundation.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:state_notifier/state_notifier.dart'; part 'counter_state.freezed.dart'; part 'counter_state.g.dart'; @freezed abstract class CounterState with _$CounterState { const factory CounterState({ @Default(0) int count, }) = _CounterState; factory CounterState.fromJson(Map<String, dynamic> json) => _$CounterStateFromJson(json); } class CounterStateNotifier extends StateNotifier<CounterState> { CounterStateNotifier() : super(const CounterState()) {} increment() => state = state.copyWith(count: state.count + 1); }その後、ターミナルで、以下のコマンドを叩いて、「counter_state.freezed.dart」「counter_state.g.dart」を自動生成してください。
flutter pub pub run build_runner buildcounter_state.dart では、
「CounterState」
クラスと「CounterStateNotifier」
クラスを定義しています。CounterState
CounterStateは、状態データの入れ物で、イミュータブルなデータクラスとして定義します。
カウンターの値の状態変数として
「int counter」
が定義されています。デフォルト値を設定するには
「@Default(0) int count」
のように記述します。
「@freezed」
アノテーションをつけることによって、copyWithメソッドが自動で生えます。copyWithメソッドは状態を更新する場合に、新しく状態を生成する(clone)ために使います。
「CounterState.fromJson」
メソッドは、今回のカウンターアプリでは使用しませんが、JSONからStateを生成する場合によく使うので記述しています。CounterStateNotifier
CounterStateNotifierは、状態を操作するロジックを管理し、Widgetに状態の変更を通知します。
MVVMモデルで解釈すると、「ViewModel」に相当します。
「inclement」
メソッドは、CouterStateのcountを1カウントアップした状態をcopyWithメソッドを使用して、新しく状態を作成し、更新しています。CounterStateNotifierを使用したWidgetを作成する
main.dartに以下のコードを記述します。
main.dartimport 'package:flutter/material.dart'; import 'package:flutter_state_notifier/flutter_state_notifier.dart'; import 'package:provider/provider.dart'; import 'package:state_notifier_example/counter_state.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( home: StateNotifierProvider<CounterStateNotifier, CounterState>( create: (_) => CounterStateNotifier(), child: HomePage(), ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('state_notifier sample'), ), body: Center( child: Text( context.select<CounterState, int>((state) => state.count).toString(), ), ), floatingActionButton: FloatingActionButton.extended( onPressed: () => context.read<CounterStateNotifier>().increment(), label: Text('1'), icon: Icon(Icons.add), ), ); } }Widgetツリーの上層で、StateNotifierProviderを作成すれば、その子Widgetであればどこでも、CounterStateNotifierを取得することができます。
ChangeNotifierを使った場合、Consumer等を挟まなくてはいけなかったのですが、state_notifierの場合スッキリ書けます。
main.darthome: StateNotifierProvider<CounterStateNotifier, CounterState>( create: (_) => CounterStateNotifier(), child: HomePage(), ),CounterStateNotifierを取得したい場合、以下のようにすれば、取得できます。
main.dartcontext.select<CounterState, int>((state) => state.count).toString(), ... onPressed: () => context.read<CounterStateNotifier>().increment(),context.readとcontext.selectには、以下の違いがあります。
context.read<CounterStateNotifier>()
- State更新時リビルドしない
- StateNotifierのメソッドを使いたいときに使う
context.select<CounterState, int>()
- State更新時リビルドする
- 状態が変わったときに画面に反映したい場合に使う
おわりに
「state_notifier」を使用することにより、簡単にWidgetから「状態」と「ロジック」を分離することができました。
「freezed」を使用することにより、Stateをデータを保持するだけのクラス、 変更不可(イミュータブル/freezed)なクラスにすることができました。コードがきれいでスッキリして、好きです。
超オススメですので、「state_notifier + freezed」を使ってみてはいかがでしょうか。
本記事のコードについて、以下のGitHubリポジトリに公開しています。
https://github.com/karamage/flutter_state_notifier_freezed_sample
- 投稿日:2020-05-16T11:56:02+09:00
「はじめてのAndroidプログラミング第4版」をAndroid Studio 3.6.3で読み進めてみると、、、
「はじめてのAndroidプログラミング第4版」の初版第2刷りをAndroid Studio 3.6.3で読み進めてみると、すぐつまづきます。(決して、誤記が原因ではない事を先にお断りいたします。)
原因は、本書ではバージョン3.4.1を対象に書かれれいるからでしょうか?(本書では、どのバージョンを対象にしているかは、はっきり書かれていなさそうですが、19ページインストールに関する図から読み解くと、おそらく3.4.1なのでしょう。)ここでは、その対処方法いくつか記述していきます。(逐次アップデートします。)
1.ファイルパスが異なる。
例えば、57ページの真ん中付近に書かれているactivity_main.xmlというファイルのファイルパス(ディレクトリ)は、本書ではapp/res/layoutですが、実際はapp/SRC/Main/res/layoutの中にあります。
ここで、ファイルの探し方は、次の写真のとおりです。
2.DesignペインのTextタブが見つからない。
58ページの下側にDesignペインのTextタブの表示の仕方が描かれていますが、UIが変わっているようで、どうすればいいかわかりませんでした。
結果は、下の写真のとおり、レイアウトウインドウのDesignペインの右上の3つのボタンの内、右から3個目を押すと、Textが現れます。
3.Resourceウインドウが開かない(正確には、Resourceウインドウの開き方がわからない)
59ページ下側の図で(5)のボタンを押すと、Resourcesウインドウが開くと書かれていますが、実際には、Pick&Resourceウインドウが開きます。
この後、New String Value Resourceウインドウを開くためには、Pick a Resourceウインドウの右上付近にある[+]ボタンを押さなければなりません。(引用:https://teratail.com/questions/261931 )
(以上、2020年05月16日現在)
4.画像をペーストしてリソースに登録する
72ページ中段の図において、drawableフォルダの上で右クリック->Pasteを選択すると、下段のコピー先の選択のウインドウが開くのではなく、73ページ上段のCopyダイアログが開く。
また、保存ホルダを選択したいときは、上の図の赤丸のボタンを押すと、下の図のSelect Taget Directoryウインドウが開く。(p72の下段のコピー先選択のウインドウに相当すると思われる。)
(以上、2020年5月17日現在)(第4章については、先にアンドロイドプログラミングを知りたいため、当面読み飛ばすことにします。)
制約の解除
126ページのビューの制約の解除の方法が変わっています。
まずImageViewをドラッグし右に移動します。
青い矢印線が見えてきたらドロップし、青い矢印線上で右クリックをします。
リストの中からClear Constraints of Selectionを選択すると、制限が解除されます。
(以上、2020年5月18日)
- 投稿日:2020-05-16T05:24:48+09:00
【最新】Android Studioのダウンロード・インストール方法
【最新】Android Studioの開発環境構築
最近アプリを作る機会が増えてきたので、備忘録として残しておこうかなと思っています。
Androidアプリを開発し始めてから3年程度ですが、それなりに勉強してきたのと、信憑性を十分に確認したうえで発信しています。
Android Studioのダウンロード
まず、公式サイトからAndroid Studioをダウンロードし、起動します。
https://developer.android.com/studio?hl=jaAndroid Studioのインストール
基本、設定は変えずに「Finish」まで、「Next」をクリックしていきます。
あっという間に、インストールまで完了です。
AVD(AndroidVirtualDevice)のインストール
AVDってなに?
AVDとは、仮想Androidです。
アプリを作る際に、実機を用いて行う場合は必要ありませんが、実際、実機だけでテストをする!という訳にはいかないのです。
例えば、Xperiaは大丈夫だけど、Galaxyだとバグが出る・・・等の問題もツキモノだからです。
本題のインストール方法
AndroidStudioを起動し、AVDマネージャーを開く
右下に、ダウンロードの表示が出ていると思うので、終わるまで待っていれば、完了です。
- 投稿日:2020-05-16T02:20:40+09:00
【超爆速 秒でコーディング】【Flutter】Adobe 公式 XD プラグインでXDからFlutterに変換してみた
XD→Flutterがやばい
この変換が30秒でできます。
↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
AdobeがXDのプラグインによるFlutterの公式サポート開始
昨年のFlutter Interactイベントでは、Adobe から、Flutterコードをツールから直接エクスポートするAdobe XDのプラグインの初期プロトタイプのデモンストレーションが発表されました。
→ adobe公式ブログ
→ YouTubeに掲載されている昨年のFlutter Interact2020年05月14日より、AdobeのXD to Flutterプラグインがより広範なパブリックテストの早期アクセスとして利用できるようになりました。
XD→Flutter変換方法
変換するXDはこちら
プラグインをインストール後にXDからPluginを確認すると以下のような画面が表示されます。
FLUTTER PROJECT
Flutter projectの項目にパスを設定すると以下のエラーが発生します。
Could not find dependencies entry in pubspec.yaml for:adobe_xd.
以下を追記して、Flutterに
adobe_xd
パッケージを導入することでエラーは解決しました。Pubspec.yamldependencies: flutter: sdk: flutter cupertino_icons: ^0.1.2 adobe_xd:CODE PATH
通常は
lib
を選択すれば問題ありません。IMAGE PATH
画像を保存するパスを指定します。
私の場合は以下のディレクトリ構成のため、
assets/images
を指定しました。WIDGET NAME PREFIX
自動生成されるDartファイルのプリフィックスになります。
{WIDGET NAME PREFIX}{アートボード名}
が生成されます.Export All Widgetsを押下
実際に生成されたのがこちら
assets/images/skylar-em-mybmb.jpg
XD_list_screen.dart
XD_play_screen.dart
FlutterをRunしてみたらこんな感じ
生成されたDartファイルはこんな感じ
XD_play_screen.dartimport 'package:flutter/material.dart'; import 'dart:ui' as ui; import 'package:flutter_svg/flutter_svg.dart'; class XD_play_screen extends StatelessWidget { XD_play_screen({ Key key, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xffe8ecef), body: Stack( children: <Widget>[ Transform.translate( offset: Offset(-52.97, -70.64), child: // Adobe XD layer: 'back_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(88.97, 114.37), child: // Adobe XD layer: 'Backward arrow' (group) SvgPicture.string( _shapeSVG_383546ed0a284f858ac75e39984bace7, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(-113.97, 232.36), child: // Adobe XD layer: 'prev_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(201.02, 454.97), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(201.02, 454.97), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(216.11, 464.71), child: SvgPicture.string( _shapeSVG_cc8eebda6f964eb89a9d866fa47aad1d, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(-65.97, 262.36), child: // Adobe XD layer: 'next_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(312.89, 424.99), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(312.89, 424.99), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(321.02, 434.79), child: SvgPicture.string( _shapeSVG_ee800ac22d2b4193b83a78fb44bc30c0, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(63.03, -24.64), child: // Adobe XD layer: 'menu_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(261.14, 69.52), child: // Adobe XD layer: 'Menu' (group) SvgPicture.string( _shapeSVG_91dee9b001434d16b9d8288df49420ed, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(261.14, 68.56), child: // Adobe XD layer: 'Menu' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 10.5, height: 10.5, decoration: BoxDecoration(), ), ), ], ), ), ], ), ), Transform.translate( offset: Offset(-90.97, 246.36), child: // Adobe XD layer: 'stop_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(247.99, 431.35), child: Container( width: 60.0, height: 60.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(30.0, 30.0)), gradient: LinearGradient( begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: [ const Color(0xffff00c4), const Color(0xff000000) ], stops: [0.0, 1.0], ), boxShadow: [ BoxShadow( color: const Color(0x5e000000), offset: Offset(3, 3), blurRadius: 6) ], ), ), ), Transform.translate( offset: Offset(271.58, 450.51), child: SvgPicture.string( _shapeSVG_aec0aade47ee4ca5b903f27d7de86884, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(22.29, 566.95), child: SvgPicture.string( _shapeSVG_25f81fa21ab349f89461efc916412212, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(-48.21, 204.45), child: Stack( children: <Widget>[], ), ), Transform.translate( offset: Offset(22.0, 601.0), child: Text( '00.00', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff898c8d), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(302.0, 601.0), child: Text( '05.36', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff898c8d), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(315.0, 646.0), child: // Adobe XD layer: 'Merge' (group) Stack( children: <Widget>[ Container( width: 20.0, height: 20.0, decoration: BoxDecoration(), ), Transform.translate( offset: Offset(0.0, 1.49), child: SvgPicture.string( _shapeSVG_00696ad8541d467faa4d8ea05320fccc, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(35.0, 646.0), child: // Adobe XD layer: 'Refresh' (group) SvgPicture.string( _shapeSVG_ecc8a9fa818245168f8aef45d5432b1b, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(63.0, 169.79), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(-101.1, 0.0), child: // Adobe XD layer: 'skylar-em-mybmb' (shape) Container( width: 438.1, height: 246.4, decoration: BoxDecoration( image: DecorationImage( image: const AssetImage( 'assets/images/skylar-em-mybmb.jpg'), fit: BoxFit.fill, ), ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 248.0, height: 248.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(124.02, 124.02)), color: const Color(0xffffffff), border: Border.all( width: 1.0, color: const Color(0xff707070)), ), ), ), ], ), ), Transform.translate( offset: Offset(110.88, 422.89), child: Text( 'Leaving Heaven', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(155.95, 453.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0x94000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), ], ), ); } } const String _shapeSVG_383546ed0a284f858ac75e39984bace7 = '<svg viewBox="89.0 114.4 13.6 13.6" ><g transform="translate(88.97, 114.37)"><path transform="matrix(-1.0, 0.0, 0.0, -1.0, 13.61, 13.61)" d="M 6.803047180175781 0 L 5.5661301612854 1.236917734146118 L 10.24874687194824 5.919534683227539 L 0 5.919534683227539 L 1.024800016592356e-16 7.686559677124023 L 10.24874687194824 7.686559677124023 L 5.5661301612854 12.36917686462402 L 6.803047180175781 13.60609436035156 L 13.60609436035156 6.803047180175781 L 6.803047180175781 0 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_cc8eebda6f964eb89a9d866fa47aad1d = '<svg viewBox="216.1 464.7 16.9 16.3" ><g transform="translate(216.11, 464.71)"><path transform="matrix(0.866025, 0.5, -0.5, 0.866025, 5.72, 0.0)" d="M 6.432093620300293 0 L 12.8641881942749 11.43483924865723 L 0 11.43483924865723 Z" fill="#920070" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(0.44, 2.65)" d="M 0 0 L 8.881784197001252e-16 13.64728927612305" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_ee800ac22d2b4193b83a78fb44bc30c0 = '<svg viewBox="321.0 434.8 16.9 16.7" ><g transform="translate(321.02, 434.79)"><path transform="matrix(0.866025, -0.5, 0.5, 0.866025, 0.0, 6.43)" d="M 6.432093620300293 0 L 12.8641881942749 11.43483924865723 L 0 11.43483924865723 Z" fill="#920070" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(16.42, 3.04)" d="M 0 0 L 8.881784197001252e-16 13.64728927612305" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_91dee9b001434d16b9d8288df49420ed = '<svg viewBox="261.1 69.5 10.5 10.5" ><g transform="translate(261.14, 68.56)"><path transform="translate(0.0, 0.96)" d="M 6.106226635438361e-16 10.52874183654785 L 5.233715995921835e-16 9.024304389953613 L 6.580931663513184 9.024304389953613 L 6.580931663513184 10.52874183654785 L 6.106226635438361e-16 10.52874183654785 Z M 3.489369166873035e-16 6.01659107208252 L 2.616857997960918e-16 4.512152194976807 L 10.52874183654785 4.512152194976807 L 10.52874183654785 6.01659107208252 L 3.489369166873035e-16 6.01659107208252 Z M 8.725107718654236e-17 1.504438281059265 L 0 0 L 10.52874183654785 6.106226635438361e-16 L 10.52874183654785 1.504438281059265 L 8.725107718654236e-17 1.504438281059265 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_aec0aade47ee4ca5b903f27d7de86884 = '<svg viewBox="271.6 450.5 11.1 20.5" ><g transform="translate(271.58, 450.51)"><path transform="translate(0.0, 0.0)" d="M 0 0 L 1.188911008996542e-15 20.49995613098145" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(11.15, 0.0)" d="M 0 0 L 1.165734175856414e-15 20.49995613098145" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_25f81fa21ab349f89461efc916412212 = '<svg viewBox="22.3 566.9 326.1 29.4" ><g transform="translate(-48.21, 204.45)"><g transform="translate(70.5, 362.5)"><path transform="translate(0.0, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.94, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(13.88, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(20.81, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(27.75, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(34.69, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(41.63, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(48.57, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(55.51, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(62.44, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(69.38, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(76.32, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(83.26, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(90.2, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(97.14, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(104.07, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g><g transform="translate(181.51, 362.5)"><path transform="translate(0.0, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.94, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(13.88, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(20.81, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(27.75, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(34.69, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(41.63, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(48.57, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(55.51, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(62.44, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(69.38, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(76.32, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(83.26, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(90.2, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(97.14, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(104.07, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#920070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g><g transform="translate(292.53, 362.5)"><path transform="translate(0.0, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.94, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(13.88, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(20.81, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(27.75, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(34.69, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(41.63, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(48.57, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(55.51, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(62.44, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(69.38, 8.4)" d="M 0 0 L 0 12.60015106201172" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(76.32, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(83.26, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(90.2, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(97.14, 0.0)" d="M 0 0 L 0 29.40035057067871" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(104.07, 4.2)" d="M 0 0 L 0 21.00025177001953" fill="none" stroke="#707070" stroke-width="1.5" stroke-miterlimit="4" stroke-linecap="butt" /></g></g></svg>'; const String _shapeSVG_00696ad8541d467faa4d8ea05320fccc = '<svg viewBox="0.0 1.5 19.9 16.4" ><path transform="translate(0.0, 1.49)" d="M 15.23306465148926 14.06151008605957 L 13.47568225860596 14.06151008605957 C 11.25159454345703 14.06151008605957 9.25841236114502 12.77399349212646 8.203941345214844 10.77989292144775 L 6.09520435333252 6.675831317901611 C 5.507744312286377 5.391578197479248 4.217473983764648 4.686625957489014 2.927101612091064 4.686625957489014 L 0 4.686625957489014 L 0 2.344944953918457 L 2.927101612091064 2.344944953918457 C 5.156288146972656 2.344944953918457 7.149572849273682 3.634093523025513 8.203941345214844 5.628194332122803 L 10.31267833709717 9.728992462158203 C 10.89503955841064 11.01324462890625 12.18541145324707 11.71819686889648 13.47568225860596 11.71819686889648 L 15.23306465148926 11.71819686889648 L 15.23306465148926 9.373251914978027 L 19.92254638671875 12.89311695098877 L 15.23306465148926 16.40645599365234 L 15.23306465148926 14.06151008605957 Z M 0 14.06151008605957 L 0 11.71819686889648 L 2.927101612091064 11.71819686889648 C 3.981470108032227 11.71819686889648 4.804831981658936 11.36735248565674 5.507744312286377 10.66240119934082 L 5.974754810333252 11.83405780792236 C 6.210656642913818 12.18979740142822 6.326108932495117 12.53574562072754 6.562214851379395 12.77399349212646 C 5.623298645019531 13.59480571746826 4.332926273345947 14.06151008605957 2.927101612091064 14.06151008605957 L 0 14.06151008605957 Z M 15.23306465148926 4.686625957489014 L 13.47568225860596 4.686625957489014 C 12.42141532897949 4.686625957489014 11.60305118560791 5.039102077484131 10.89503955841064 5.742422580718994 L 10.42823314666748 4.572397708892822 C 10.1921272277832 4.218289852142334 10.07667446136475 3.870709419250488 9.84077262878418 3.634093523025513 C 10.77958679199219 2.811649322509766 12.06995964050293 2.344944953918457 13.47568225860596 2.344944953918457 L 15.23306465148926 2.344944953918457 L 15.23306465148926 0 L 19.92254638671875 3.511705875396729 L 15.23306465148926 7.033202648162842 L 15.23306465148926 4.686625957489014 Z" fill="#898c8d" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></svg>'; const String _shapeSVG_ecc8a9fa818245168f8aef45d5432b1b = '<svg viewBox="35.0 646.0 20.0 20.0" ><g transform="translate(35.0, 646.0)"><path d="M 6.25 12.5 C 6.875 12.5 7.125000476837158 12.875 6.625000476837158 13.375 L 4.750000476837158 15.25 C 6.125 16.62500190734863 8.000000953674316 17.5 10 17.5 C 13.875 17.5 17 14.625 17.5 11 C 17.5 11 17.625 10 18.75 10 C 19.5 10 20 10.5 20 11.25 C 20 11.37500095367432 20 11.37500095367432 20 11.5 C 19.25 16.375 15.125 20 10 20 C 7.25 20 4.750000476837158 18.875 2.875000238418579 17.125 L 0.8750001788139343 19.12500190734863 C 0.3750000298023224 19.625 0 19.375 0 18.75 L 0 13.125 C 0 12.75 0.2500000298023224 12.5 0.625 12.5 L 6.25 12.5 Z M 13.75 7.5 C 13.125 7.5 12.875 7.125000476837158 13.375 6.625000476837158 L 15.25 4.750000476837158 C 13.875 3.375000238418579 12.00000095367432 2.5 10 2.5 C 6.125 2.5 3.000000238418579 5.375000476837158 2.5 9 C 2.5 9 2.375 10 1.25 10 C 0.5000000596046448 10 0 9.5 0 8.75 C 0 8.625000953674316 0 8.625000953674316 0 8.5 C 0.7500000596046448 3.625 4.875 0 10 0 C 12.75 0 15.25 1.125 17.125 2.875 L 19.12500190734863 0.875 C 19.625 0.3750000298023224 20 0.625 20 1.25 L 20 6.875 C 20 7.25 19.75000190734863 7.5 19.375 7.5 L 13.75 7.5 Z" fill="#898c8d" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>';XD_list_screen.dartimport 'package:flutter/material.dart'; import 'dart:ui' as ui; import 'package:flutter_svg/flutter_svg.dart'; import 'package:adobe_xd/specific_rect_clip.dart'; class XD_list_screen extends StatelessWidget { XD_list_screen({ Key key, }) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xffe8ecef), body: Stack( children: <Widget>[ Transform.translate( offset: Offset(-52.97, -70.64), child: // Adobe XD layer: 'back_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(75.27, 98.82), child: Container( width: 40.0, height: 41.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.65)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(88.97, 114.37), child: // Adobe XD layer: 'Backward arrow' (group) SvgPicture.string( _shapeSVG_96eb9fb5134542799a4150a6bd089216, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(63.03, -24.64), child: // Adobe XD layer: 'menu_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(245.37, 53.11), child: Container( width: 40.0, height: 40.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(20.0, 20.0)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(261.14, 69.52), child: // Adobe XD layer: 'Menu' (group) SvgPicture.string( _shapeSVG_020664c98c7f40988eec662dd04f1eaa, allowDrawingOutsideViewBox: true, ), ), Transform.translate( offset: Offset(261.14, 68.56), child: // Adobe XD layer: 'Menu' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 10.5, height: 10.5, decoration: BoxDecoration(), ), ), ], ), ), ], ), ), Transform.translate( offset: Offset(116.0, 105.79), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(-58.41, 0.0), child: // Adobe XD layer: 'skylar-em-mybmb' (shape) Container( width: 253.1, height: 142.4, decoration: BoxDecoration( image: DecorationImage( image: const AssetImage( 'assets/images/skylar-em-mybmb.jpg'), fit: BoxFit.fill, ), ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 143.3, height: 143.3, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(71.65, 71.65)), color: const Color(0xffffffff), border: Border.all( width: 1.0, color: const Color(0xff707070)), ), ), ), ], ), ), Transform.translate( offset: Offset(118.88, 28.89), child: Text( 'Leaving Heaven', style: TextStyle( fontFamily: 'Open Sans', fontSize: 18, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(163.95, 59.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0x94000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(21.0, 299.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_76d2aedc11af4dc482a870903997dcff, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 369.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 439.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_bce56cc9a5f84e1aa05ddfa22e0b2068, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 509.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_ed54718f34e74e9dac15b29107fa6038, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 579.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_676e0afef6c443d280271cbb62d129df, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 649.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_4896be0f759c4917bc911be2e9723cf9, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(21.0, 719.0), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Text( 'Rap God', style: TextStyle( fontFamily: 'Open Sans', fontSize: 19, color: const Color(0xff000000), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(0.0, 31.0), child: Text( 'Eminem', style: TextStyle( fontFamily: 'Open Sans', fontSize: 14, color: const Color(0xff616364), fontWeight: FontWeight.w600, ), textAlign: TextAlign.left, ), ), Transform.translate( offset: Offset(297.38, 6.2), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x96ffffff), offset: Offset(-2, -2), blurRadius: 1.5) ], ), ), ), Transform.translate( offset: Offset(0.0, 0.0), child: Container( width: 35.6, height: 35.6, decoration: BoxDecoration( borderRadius: BorderRadius.all( Radius.elliptical(17.81, 17.81)), color: const Color(0xffebecf0), boxShadow: [ BoxShadow( color: const Color(0x24000000), offset: Offset(3, 3), blurRadius: 1.5) ], ), ), ), ], ), ), Transform.translate( offset: Offset(309.77, 13.94), child: SvgPicture.string( _shapeSVG_8ed41034be1b456fa5327cb28a2be403, allowDrawingOutsideViewBox: true, ), ), ], ), ), Transform.translate( offset: Offset(-49.93, -393.81), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(56.93, 751.81), child: Container( width: 359.0, height: 74.0, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.0), border: Border.all( width: 1.0, color: const Color(0xffe8ecef)), boxShadow: [ BoxShadow( color: const Color(0x45000000), offset: Offset(1, 1), blurRadius: 1) ], ), ), ), Transform.translate( offset: Offset(54.43, 749.31), child: SpecificRectClip( rect: Rect.fromLTWH(0, 0, 362, 77), child: UnconstrainedBox( alignment: Alignment.topLeft, child: Container( width: 362, height: 77, child: GridView.count( primary: false, padding: EdgeInsets.all(0), mainAxisSpacing: 20, crossAxisSpacing: 20, crossAxisCount: 1, childAspectRatio: 4.701298701298701, children: [ {}, ].map((map) { return Transform.translate( offset: Offset(-54.43, -749.31), child: Stack( children: <Widget>[ Transform.translate( offset: Offset(56.93, 751.81), child: Container( width: 359.0, height: 74.0, decoration: BoxDecoration( borderRadius: BorderRadius.circular(16.0), border: Border.all( width: 1.0, color: const Color(0xffe8ecef)), boxShadow: [ BoxShadow( color: const Color(0x45ffffff), offset: Offset(-1, -1), blurRadius: 1) ], ), ), ), ], ), ); }).toList(), ), ), ), ), ), Transform.translate( offset: Offset(120.96, 340.17), child: // Adobe XD layer: 'stop_btn' (group) Stack( children: <Widget>[ Transform.translate( offset: Offset(247.99, 431.35), child: Container( width: 35.0, height: 35.0, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.elliptical(17.5, 17.5)), gradient: LinearGradient( begin: Alignment(0.0, -1.0), end: Alignment(0.0, 1.0), colors: [ const Color(0xffff00c4), const Color(0xff000000) ], stops: [0.0, 1.0], ), boxShadow: [ BoxShadow( color: const Color(0x5e000000), offset: Offset(3, 3), blurRadius: 6) ], ), ), ), Transform.translate( offset: Offset(261.63, 442.3), child: SvgPicture.string( _shapeSVG_7183cc78301f48a287bb50591c2bf5cd, allowDrawingOutsideViewBox: true, ), ), ], ), ), ], ), ), ], ), ); } } const String _shapeSVG_96eb9fb5134542799a4150a6bd089216 = '<svg viewBox="89.0 114.4 13.6 13.6" ><g transform="translate(88.97, 114.37)"><path transform="matrix(-1.0, 0.0, 0.0, -1.0, 13.61, 13.61)" d="M 6.803047180175781 0 L 5.5661301612854 1.236917734146118 L 10.24874687194824 5.919534683227539 L 0 5.919534683227539 L 1.024800016592356e-16 7.686559677124023 L 10.24874687194824 7.686559677124023 L 5.5661301612854 12.36917686462402 L 6.803047180175781 13.60609436035156 L 13.60609436035156 6.803047180175781 L 6.803047180175781 0 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_020664c98c7f40988eec662dd04f1eaa = '<svg viewBox="261.1 69.5 10.5 10.5" ><g transform="translate(261.14, 68.56)"><path transform="translate(0.0, 0.96)" d="M 6.106226635438361e-16 10.52874183654785 L 5.233715995921835e-16 9.024304389953613 L 6.580931663513184 9.024304389953613 L 6.580931663513184 10.52874183654785 L 6.106226635438361e-16 10.52874183654785 Z M 3.489369166873035e-16 6.01659107208252 L 2.616857997960918e-16 4.512152194976807 L 10.52874183654785 4.512152194976807 L 10.52874183654785 6.01659107208252 L 3.489369166873035e-16 6.01659107208252 Z M 8.725107718654236e-17 1.504438281059265 L 0 0 L 10.52874183654785 6.106226635438361e-16 L 10.52874183654785 1.504438281059265 L 8.725107718654236e-17 1.504438281059265 Z" fill="#000000" fill-opacity="0.74" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>'; const String _shapeSVG_76d2aedc11af4dc482a870903997dcff = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_bce56cc9a5f84e1aa05ddfa22e0b2068 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_ed54718f34e74e9dac15b29107fa6038 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_676e0afef6c443d280271cbb62d129df = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_4896be0f759c4917bc911be2e9723cf9 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_8ed41034be1b456fa5327cb28a2be403 = '<svg viewBox="309.8 13.9 12.4 20.1" ><defs><filter id="shadow"><feDropShadow dx="3" dy="3" stdDeviation="6"/></filter></defs><path transform="matrix(0.0, 1.0, -1.0, 0.0, 322.16, 13.94)" d="M 10.06744003295898 0 L 20.13488388061523 12.39069938659668 L 0 12.39069938659668 Z" fill="#373737" stroke="none" stroke-width="1" stroke-miterlimit="4" stroke-linecap="butt" filter="url(#shadow)"/></svg>'; const String _shapeSVG_7183cc78301f48a287bb50591c2bf5cd = '<svg viewBox="261.6 442.3 6.8 12.4" ><g transform="translate(261.63, 442.3)"><path transform="translate(0.0, 0.0)" d="M 0 0 L 1.188911008996542e-15 12.42906475067139" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /><path transform="translate(6.76, 0.0)" d="M 0 0 L 1.165734175856414e-15 12.42906475067139" fill="none" stroke="#ffffff" stroke-width="3" stroke-miterlimit="4" stroke-linecap="butt" /></g></svg>';参考記事