diff --git a/add_to_app/README.md b/add_to_app/README.md index 9e5c613fe..39a640d97 100644 --- a/add_to_app/README.md +++ b/add_to_app/README.md @@ -10,11 +10,13 @@ standalone Flutter module. - Whether to build the Flutter module from source each time the app builds or rely on a separately pre-built module. - Whether plugins are needed by the Flutter module used in the app. +* Show Flutter being integrated ergonomically with applications with existing + middleware and business logic data classes. ## Installing Cocoapods The iOS samples in this repo require the latest version of Cocoapods. To install -it, run the following command on a MacOS machine: +it, run the following command on a macOS machine: ```bash sudo gem install cocoapods @@ -26,20 +28,26 @@ See https://guides.cocoapods.org/using/getting-started.html for more details. ### Flutter modules -There are two Flutter modules included in the codebase: +There are three Flutter modules included in the codebase: * `flutter_module` displays the dimensions of the screen, a button that increments a simple counter, and an optional exit button. * `flutter_module_using_plugin` does everything `flutter_module` does and adds another button that will open the Flutter documentation in a browser using the [`url_launcher`](https://pub.dev/packages/url_launcher) Flutter plugin. +* `flutter_module_books` simulates an integration scenario with existing + platform business logic and middleware. It uses the [`pigeon`](https://pub.dev/packages/pigeon) + plugin to make integration easier by generating the platform channel + interop inside wrapper API and data classes that are shared between the + platform and Flutter. + Before using them, you need to resolve the Flutter modules' dependencies. Do so by running this command from within the `flutter_module` and `flutter_module_using_plugin` directories: ```bash -flutter packages get +flutter pub get ``` ### Android and iOS applications @@ -139,6 +147,50 @@ Flutter frameworks, see this article in the Flutter GitHub wiki: https://flutter.dev/docs/development/add-to-app/ios/project-setup +### `android_books` and `ios_books (TODO)` + +These apps integrate the `flutter_books` module using the simpler build-together +project setup. They simulate a mock scenario where an existing book catalog +list app already exists. Flutter is used to implement an additional book details +page. + +* Similar to `android_fullscreen` and `ios_fullscreen`. +* An existing books catalog app is already implemented in Kotlin and Swift. +* The platform-side app has existing middleware constraints that should also + be the middleware foundation for the additional Flutter screen. + * On Android, the Kotlin app already uses GSON and OkHttp for networking and + references the Google Books API as a data source. These same libraries + also underpin the data fetched and shown in the Flutter screen. + * iOS TODO. +* The platform application interfaces with the Flutter book details page using + idiomatic platform API conventions rather than Flutter conventions. + * On Android, the Flutter activity receives the book to show via activity + intent and returns the edited book by setting the result intent on the + activity. No Flutter concepts are leaked into the consumer activity. + * iOS TODO. +* The [pigeon](https://pub.dev/packages/pigeon) plugin is used to generate + interop APIs and data classes. The same `Book` model class is used within the + Kotlin/Swift program, the Dart program and in the interop between Kotlin/Swift + and Dart. No manual platform channel plumbing needed for interop. + * The `api.dart/java/mm` files generated from the + `flutter_module_books/pigeon/schema.dart` file are checked into source + control. Therefore `pigeon` is only a dev dependency with no runtime + requirements. + * If the `schema.dart` is modified, the generated classes can be updated with + + ```shell + flutter pub run pigeon \ + --input pigeon/schema.dart \ + --java_out ../android_books/app/src/main/java/dev/flutter/example/books/Api.java \ + --java_package "dev.flutter.example.books" + ``` + + in the `flutter_module_books` directory. + +Once you've understood the basics of add-to-app with `android_fullscreen` and +`ios_fullscreen`, this is a good sample to demonstrate how to integrate Flutter +in a slightly more realistic setting with existing business logic. + ## Questions/issues If you have a general question about incorporating Flutter into an existing diff --git a/add_to_app/android_books/.gitignore b/add_to_app/android_books/.gitignore new file mode 100644 index 000000000..603b14077 --- /dev/null +++ b/add_to_app/android_books/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/add_to_app/android_books/app/.gitignore b/add_to_app/android_books/app/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/add_to_app/android_books/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/add_to_app/android_books/app/build.gradle b/add_to_app/android_books/app/build.gradle new file mode 100644 index 000000000..a0b0de94a --- /dev/null +++ b/add_to_app/android_books/app/build.gradle @@ -0,0 +1,49 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' + +android { + compileSdkVersion 29 + buildToolsVersion "29.0.3" + + defaultConfig { + applicationId "dev.flutter.example.books" + minSdkVersion 16 + targetSdkVersion 29 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation fileTree(dir: "libs", include: ["*.jar"]) + implementation "com.squareup.okhttp3:okhttp:4.7.2" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.0' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation "androidx.activity:activity-ktx:1.1.0" + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'com.google.android.material:material:1.1.0' + implementation 'com.google.code.gson:gson:2.8.6' + implementation project(path: ':flutter') + testImplementation 'junit:junit:4.13' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + +} \ No newline at end of file diff --git a/add_to_app/android_books/app/proguard-rules.pro b/add_to_app/android_books/app/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/add_to_app/android_books/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/add_to_app/android_books/app/src/androidTest/java/dev/flutter/example/books/ExampleInstrumentedTest.kt b/add_to_app/android_books/app/src/androidTest/java/dev/flutter/example/books/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..3648083dc --- /dev/null +++ b/add_to_app/android_books/app/src/androidTest/java/dev/flutter/example/books/ExampleInstrumentedTest.kt @@ -0,0 +1,27 @@ +package dev.flutter.example.books + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("dev.flutter.example.books", appContext.packageName) + } + + // The app should be hermetic (with offline books JSON) before adding + // more tests. +} diff --git a/add_to_app/android_books/app/src/main/AndroidManifest.xml b/add_to_app/android_books/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b15f0cc56 --- /dev/null +++ b/add_to_app/android_books/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/Api.java b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/Api.java new file mode 100644 index 000000000..2a43ea8b6 --- /dev/null +++ b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/Api.java @@ -0,0 +1,143 @@ +// Autogenerated from Pigeon (v0.1.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +package dev.flutter.example.books; + +import java.util.HashMap; + +import io.flutter.plugin.common.BasicMessageChannel; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.StandardMessageCodec; + +/** Generated class from Pigeon. */ +public class Api { + + /** Generated class from Pigeon that represents data sent in messages. */ + public static class Book { + private String title; + public String getTitle() { return title; } + public void setTitle(String setterArg) { this.title = setterArg; } + + private String subtitle; + public String getSubtitle() { return subtitle; } + public void setSubtitle(String setterArg) { this.subtitle = setterArg; } + + private String author; + public String getAuthor() { return author; } + public void setAuthor(String setterArg) { this.author = setterArg; } + + private String description; + public String getDescription() { return description; } + public void setDescription(String setterArg) { this.description = setterArg; } + + private String publishDate; + public String getPublishDate() { return publishDate; } + public void setPublishDate(String setterArg) { this.publishDate = setterArg; } + + private Long pageCount; + public Long getPageCount() { return pageCount; } + public void setPageCount(Long setterArg) { this.pageCount = setterArg; } + + HashMap toMap() { + HashMap toMapResult = new HashMap(); + toMapResult.put("title", title); + toMapResult.put("subtitle", subtitle); + toMapResult.put("author", author); + toMapResult.put("description", description); + toMapResult.put("publishDate", publishDate); + toMapResult.put("pageCount", pageCount); + return toMapResult; + } + static Book fromMap(HashMap map) { + Book fromMapResult = new Book(); + fromMapResult.title = (String)map.get("title"); + fromMapResult.subtitle = (String)map.get("subtitle"); + fromMapResult.author = (String)map.get("author"); + fromMapResult.description = (String)map.get("description"); + fromMapResult.publishDate = (String)map.get("publishDate"); + fromMapResult.pageCount = (map.get("pageCount") instanceof Integer) ? (Integer)map.get("pageCount") : (Long)map.get("pageCount"); + return fromMapResult; + } + } + + /** Generated class from Pigeon that represents Flutter messages that can be called from Java.*/ + public static class FlutterBookApi { + private BinaryMessenger binaryMessenger; + public FlutterBookApi(BinaryMessenger argBinaryMessenger){ + this.binaryMessenger = argBinaryMessenger; + } + public interface Reply { + void reply(T reply); + } + public void displayBookDetails(Book argInput, Reply callback) { + BasicMessageChannel channel = + new BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.FlutterBookApi.displayBookDetails", new StandardMessageCodec()); + HashMap inputMap = argInput.toMap(); + channel.send(inputMap, new BasicMessageChannel.Reply() { + public void reply(Object channelReply) { + callback.reply(null); + } + }); + } + } + + /** Generated interface from Pigeon that represents a handler of messages from Flutter.*/ + public interface HostBookApi { + void cancel(); + void finishEditingBook(Book arg); + + /** Sets up an instance of `HostBookApi` to handle messages through the `binaryMessenger` */ + static void setup(BinaryMessenger binaryMessenger, HostBookApi api) { + { + BasicMessageChannel channel = + new BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.HostBookApi.cancel", new StandardMessageCodec()); + if (api != null) { + channel.setMessageHandler(new BasicMessageChannel.MessageHandler() { + public void onMessage(Object message, BasicMessageChannel.Reply reply) { + HashMap wrapped = new HashMap(); + try { + api.cancel(); + wrapped.put("result", null); + } + catch (Exception exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + { + BasicMessageChannel channel = + new BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.HostBookApi.finishEditingBook", new StandardMessageCodec()); + if (api != null) { + channel.setMessageHandler(new BasicMessageChannel.MessageHandler() { + public void onMessage(Object message, BasicMessageChannel.Reply reply) { + Book input = Book.fromMap((HashMap)message); + HashMap wrapped = new HashMap(); + try { + api.finishEditingBook(input); + wrapped.put("result", null); + } + catch (Exception exception) { + wrapped.put("error", wrapError(exception)); + } + reply.reply(wrapped); + } + }); + } else { + channel.setMessageHandler(null); + } + } + } + } + private static HashMap wrapError(Exception exception) { + HashMap errorMap = new HashMap(); + errorMap.put("message", exception.toString()); + errorMap.put("code", null); + errorMap.put("details", null); + return errorMap; + } +} diff --git a/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/BookApplication.kt b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/BookApplication.kt new file mode 100644 index 000000000..11941b093 --- /dev/null +++ b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/BookApplication.kt @@ -0,0 +1,28 @@ +// Copyright 2020 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.example.books + +import android.app.Application +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.embedding.engine.dart.DartExecutor + +class BookApplication: Application() { + companion object { + const val ENGINE_ID = "book_engine" + } + + private lateinit var flutterEngine: FlutterEngine + + override fun onCreate() { + super.onCreate() + // This application reuses a single FlutterEngine instance throughout. + // Create the FlutterEngine on application start. + flutterEngine = FlutterEngine(this).apply{ + dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault()) + } + FlutterEngineCache.getInstance().put(ENGINE_ID, flutterEngine) + } +} diff --git a/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/FlutterBookActivity.kt b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/FlutterBookActivity.kt new file mode 100644 index 000000000..554492387 --- /dev/null +++ b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/FlutterBookActivity.kt @@ -0,0 +1,101 @@ +// Copyright 2020 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.example.books + +import android.app.Activity +import android.content.Context +import android.content.Intent +import dev.flutter.example.books.BookApplication.Companion.ENGINE_ID +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import java.util.HashMap + +/** + * This {@link FlutterActivity} class repackages Kotlin-Dart interop using the Pigeon IPC mechanism. + * It repackages Flutter/Dart-side functionalities in standard Android API style, passing + * arguments in and out of the activity using 'startActivityForResult' intents and + * 'onActivityResult' intents. + */ +class FlutterBookActivity: FlutterActivity() { + companion object { + const val EXTRA_BOOK = "book" + + /** + * Static intent factory to start {@link FlutterBookActivity} with the singleton + * {@link FlutterEngine} the application started. + * + * The activity launched from this intent shows the details of the {@link Api.Book} + * supplied. + */ + fun withBook(context: Context, book: Api.Book): Intent { + // In a more realistic app, there should be some dependency injection mechanism to + // determine which engine to use. + return CachedEngineBookIntentBuilder(ENGINE_ID) + .build(context) + .putExtra( + // The Pigeon data class is useful not only between Kotlin/Java and Dart + // but also within Kotlin/Java where activities must communicate with + // each other via serializable data. The Pigeon data class is a + // serializable class by definition. + EXTRA_BOOK, + // TODO(gaaclarke): the Pigeon generated data class should just implement + // Serializable so we won't need 'toMap()' here + // https://github.com/flutter/flutter/issues/58909 + book.toMap() + ) + } + + /** + * A static helper method to parse a result intent from this activity into a {@link Book}. + * + * @param resultIntent an {@link Intent} that must be the data intent returned by this + * activity's {@code onActivityResult}. + */ + fun getBookFromResultIntent(resultIntent: Intent): Api.Book { + return Api.Book.fromMap(resultIntent.getSerializableExtra(FlutterBookActivity.EXTRA_BOOK) as HashMap<*, *>); + } + } + + // Intent builder class to build a FlutterBookActivity instance instead of the default FlutterActivity. + class CachedEngineBookIntentBuilder(engineId: String): CachedEngineIntentBuilder(FlutterBookActivity::class.java, engineId) { } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + // Called shortly after the activity is created, when the activity is bound to a + // FlutterEngine responsible for rendering the Flutter activity's content. + super.configureFlutterEngine(flutterEngine) + + // The book to give to Flutter is passed in from the MainActivity via this activity's + // source intent getter. The intent contains the book serialized as on extra. + val bookToShow = Api.Book.fromMap(intent.getSerializableExtra(EXTRA_BOOK) as HashMap<*, *>) + + // Register the HostBookApiHandler callback class to get results from Flutter. + Api.HostBookApi.setup(flutterEngine.dartExecutor, HostBookApiHandler()) + + // Send in the book instance to Flutter. + Api.FlutterBookApi(flutterEngine.dartExecutor).displayBookDetails(bookToShow) { + // We don't care about the callback + } + } + + // This {@link Api.HostBookApi} subclass will be called by Pigeon when the corresponding + // APIs are invoked on the Dart side. + inner class HostBookApiHandler: Api.HostBookApi { + override fun cancel() { + // Flutter called cancel. Finish the activity with a cancel result. + setResult(Activity.RESULT_CANCELED) + finish() + } + + override fun finishEditingBook(book: Api.Book?) { + if (book == null) { + throw IllegalArgumentException("finishedEditingBook cannot be called with a null argument") + } + // Flutter returned an edited book instance. Return it to the MainActivity via the + // standard Android Activity set result mechanism. + setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_BOOK, book.toMap())) + finish() + } + } +} diff --git a/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/MainActivity.kt b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/MainActivity.kt new file mode 100644 index 000000000..78f442513 --- /dev/null +++ b/add_to_app/android_books/app/src/main/java/dev/flutter/example/books/MainActivity.kt @@ -0,0 +1,166 @@ +// Copyright 2020 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package dev.flutter.example.books + +import android.app.Activity +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.ProgressBar +import android.widget.TextView +import com.google.android.material.button.MaterialButton +import com.google.gson.GsonBuilder +import com.google.gson.JsonParser +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import java.lang.Exception +import java.lang.RuntimeException + +class MainActivity : AppCompatActivity() { + companion object { + const val BOOKS_QUERY = "https://www.googleapis.com/books/v1/volumes?q=greenwood+tulsa&maxResults=15" + } + + private lateinit var books: MutableList + private lateinit var list: LinearLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + list = findViewById(R.id.list) + + // OkHttp is arbitrarily chosen here to represent an existing middleware constraint that's + // already present in an existing application's infrastructure. + val httpClient = OkHttpClient() + val bookRequest = Request.Builder() + // Retrieve data from Google Books API (arbitrarily chosen). This represents existing + // data sources that an existing application is already interfacing with. + .url(BOOKS_QUERY) + .build() + + httpClient.newCall(bookRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + throw e + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) throw IOException("Unexpected code $response") + books = parseGoogleBooksJsonToBooks(response.body!!.string()) + + val spinner = findViewById(R.id.spinner) + + runOnUiThread { + // Showed a spinner while network call is in progress. Remove it when + // response is received. + spinner.visibility = View.GONE + populateBookCards() + } + } + } + }) + } + + // Take a top level Google Books query's response JSON and create a list of Book Pigeon data + // classes that will be used both as a model here on the Kotlin side and also in the IPCs + // to Dart. + private fun parseGoogleBooksJsonToBooks(jsonBody: String): MutableList { + // Here we're arbitrarily using GSON to represent another existing middleware constraint + // that already exists in your existing application's infrastructure. + val jsonBooks = JsonParser.parseString(jsonBody).asJsonObject.getAsJsonArray("items") + val books = mutableListOf() + for (jsonBook in jsonBooks.map { it.asJsonObject }) { + try { + // Here we're using GSON to populate a Pigeon data class directly. The Pigeon data + // class can be used not just as part of your IPC API's signature but also as a + // normal data class in your existing application. + // + // We could either push the Pigeon data class usage higher into the existing GSON + // "middleware" or lower, closer to the IPC. + val book = Api.Book() + val volumeInfoJson = jsonBook.getAsJsonObject("volumeInfo") + book.title = volumeInfoJson.get("title").asString + book.subtitle = volumeInfoJson.get("subtitle")?.asString + // Sorry co-authors, we're trying to keep this simple. + book.author = volumeInfoJson.getAsJsonArray("authors")[0].asString + book.description = volumeInfoJson.get("description").asString + book.publishDate = volumeInfoJson.get("publishedDate").asString + book.pageCount = volumeInfoJson.get("pageCount").asLong + books.add(book) + } catch (e: Exception) { + println("Failed to parse book:") + println(GsonBuilder().setPrettyPrinting().create().toJson(jsonBook)) + println("Parsing error:") + println(e) + } + } + return books + } + + // Given a populated books list, create a Material Design card in a scroll view for each book. + private fun populateBookCards() { + for ((index, book) in books.withIndex()) { + val card = layoutInflater.inflate(R.layout.book_card, null) + updateCardWithBook(card, book) + card.findViewById(R.id.edit).setOnClickListener { + // When the edit button is clicked in a book's card, launch a Flutter activity + // showing the details of the book. + startActivityForResult( + // We're using our own 'FlutterActivity' subclass which wraps Pigeon API usages + // into an idiomatic Android activity interface with intent extras as input and + // with activity 'setResult' as output. + // + // This lets activity-level feature developers abstract their Flutter usage + // and present a standard Android API to their upstream application developers. + // + // No Flutter-specific concepts are leaked outside the Flutter activity itself + // into the consuming class. + FlutterBookActivity + // Re-read from the 'books' list rather than just capturing the iterated + // 'book' instance since we change it when Dart updates it in onActivityResult. + .withBook(this, books[index]), + // The index lets us know which book we're returning the result for when we + // return from the Flutter activity. + index) + } + list.addView(card) + } + } + + // Given a Material Design card and a book, update the card content to reflect the book model. + private fun updateCardWithBook(card: View, book: Api.Book) { + card.findViewById(R.id.title).text = book.title + card.findViewById(R.id.subtitle).text = book.subtitle + card.findViewById(R.id.author).text = resources.getString(R.string.author_prefix, book.author) + } + + // Callback when the Flutter activity started with 'startActivityForResult' above returns. + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + // The Flutter activity may cancel the edit. If so, don't update anything. + if (resultCode == Activity.RESULT_OK) { + if (data == null) { + throw RuntimeException("The FlutterBookActivity returning RESULT_OK should always have a return data intent") + } + + // If the book was edited in Flutter, the Flutter activity finishes and returns an + // activity result in an intent (the 'data' argument). The intent has an extra which is + // the edited book in serialized form. + val returnedBook = FlutterBookActivity.getBookFromResultIntent(data) + // Update our book model list. + books[requestCode] = returnedBook + + // Refresh the UI here on the Kotlin side. + updateCardWithBook(list.getChildAt(requestCode), returnedBook) + } + } +} diff --git a/add_to_app/android_books/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/add_to_app/android_books/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/res/drawable/ic_launcher_background.xml b/add_to_app/android_books/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/add_to_app/android_books/app/src/main/res/layout/activity_main.xml b/add_to_app/android_books/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..061bdecc8 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/res/layout/book_card.xml b/add_to_app/android_books/app/src/main/res/layout/book_card.xml new file mode 100644 index 000000000..9ac178557 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/layout/book_card.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/add_to_app/android_books/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/add_to_app/android_books/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/add_to_app/android_books/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/res/mipmap-hdpi/ic_launcher.png b/add_to_app/android_books/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a571e6009 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/add_to_app/android_books/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..61da551c5 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-mdpi/ic_launcher.png b/add_to_app/android_books/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c41dd2853 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/add_to_app/android_books/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..db5080a75 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/add_to_app/android_books/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..6dba46dab Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/add_to_app/android_books/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..da31a871c Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/add_to_app/android_books/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..15ac68172 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/add_to_app/android_books/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..b216f2d31 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/add_to_app/android_books/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..f25a41974 Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/add_to_app/android_books/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/add_to_app/android_books/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..e96783ccc Binary files /dev/null and b/add_to_app/android_books/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/add_to_app/android_books/app/src/main/res/values/colors.xml b/add_to_app/android_books/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..4faecfa80 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #6200EE + #3700B3 + #03DAC5 + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/res/values/strings.xml b/add_to_app/android_books/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..aca1b9f75 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + Books + Edit + By: %1$s + \ No newline at end of file diff --git a/add_to_app/android_books/app/src/main/res/values/styles.xml b/add_to_app/android_books/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..97150c772 --- /dev/null +++ b/add_to_app/android_books/app/src/main/res/values/styles.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/add_to_app/android_books/build.gradle b/add_to_app/android_books/build.gradle new file mode 100644 index 000000000..71b78e029 --- /dev/null +++ b/add_to_app/android_books/build.gradle @@ -0,0 +1,26 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.kotlin_version = "1.3.72" + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.0.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/add_to_app/android_books/gradle.properties b/add_to_app/android_books/gradle.properties new file mode 100644 index 000000000..4d15d015f --- /dev/null +++ b/add_to_app/android_books/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/add_to_app/android_books/gradle/wrapper/gradle-wrapper.jar b/add_to_app/android_books/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..f6b961fd5 Binary files /dev/null and b/add_to_app/android_books/gradle/wrapper/gradle-wrapper.jar differ diff --git a/add_to_app/android_books/gradle/wrapper/gradle-wrapper.properties b/add_to_app/android_books/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..531ba7ec5 --- /dev/null +++ b/add_to_app/android_books/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sat Jun 06 17:49:00 PDT 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/add_to_app/android_books/gradlew b/add_to_app/android_books/gradlew new file mode 100755 index 000000000..cccdd3d51 --- /dev/null +++ b/add_to_app/android_books/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/add_to_app/android_books/gradlew.bat b/add_to_app/android_books/gradlew.bat new file mode 100644 index 000000000..e95643d6a --- /dev/null +++ b/add_to_app/android_books/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/add_to_app/android_books/settings.gradle b/add_to_app/android_books/settings.gradle new file mode 100644 index 000000000..aa9305d70 --- /dev/null +++ b/add_to_app/android_books/settings.gradle @@ -0,0 +1,10 @@ +include ':app' +setBinding(new Binding([gradle: this])) +evaluate(new File( + settingsDir, + '../flutter_module_books/.android/include_flutter.groovy' +)) +rootProject.name = "Android Books" + +include ':flutter_module_books' +project(':flutter_module_books').projectDir = new File('../flutter_module_books') diff --git a/add_to_app/flutter_module_books/.gitignore b/add_to_app/flutter_module_books/.gitignore new file mode 100644 index 000000000..ff612b3be --- /dev/null +++ b/add_to_app/flutter_module_books/.gitignore @@ -0,0 +1,48 @@ +.DS_Store +.dart_tool/ + +.packages +.pub/ + +.idea/ +.vagrant/ +.sconsign.dblite +.svn/ + +*.swp +profile + +DerivedData/ + +.generated/ + +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 + +!default.pbxuser +!default.mode1v3 +!default.mode2v3 +!default.perspectivev3 + +xcuserdata + +*.moved-aside + +*.pyc +*sync/ +Icon? +.tags* + +build/ +.android/ +.ios/ +.flutter-plugins +.flutter-plugins-dependencies + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/add_to_app/flutter_module_books/.metadata b/add_to_app/flutter_module_books/.metadata new file mode 100644 index 000000000..37f72324f --- /dev/null +++ b/add_to_app/flutter_module_books/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: c0091b289e9966b6da99647b2f18dcb93de1f207 + channel: master + +project_type: module diff --git a/add_to_app/flutter_module_books/README.md b/add_to_app/flutter_module_books/README.md new file mode 100644 index 000000000..8c9dcc7cf --- /dev/null +++ b/add_to_app/flutter_module_books/README.md @@ -0,0 +1,42 @@ +# Books add-to-app sample + +This application simulates a mock scenario where an existing app with +business logic and middleware already exists. This sample demonstrates how to +do an add-to-app Flutter integration into existing conventions. + +This application also utilizes the [Pigeon](https://pub.dev/packages/pigeon) +plugin to avoid manual platform channel wiring. Pigeon autogenerates the +platform channel code in Dart/Java/Objective-C to allow interop using higher +order functions and data classes instead of string-encoded methods and +serialized primitives. + +The Pigeon autogenerated code is checked-in and ready to use. If the schema +in `pigeon/schema.dart` is updated, the generated classes can also be re- +generated using: + +```shell +flutter pub run pigeon \ + --input pigeon/schema.dart \ + --java_out ../android_books/app/src/main/java/dev/flutter/example/books/Api.java \ + --java_package "dev.flutter.example.books" +``` + +## Demonstrated concepts + +* An existing books catalog app is already implemented in Kotlin and Swift. +* The platform-side app has existing middleware constraints that should also + be the middleware foundation for the additional Flutter screen. + * On Android, the Kotlin app already uses GSON and OkHttp for networking and + references the Google Books API as a data source. These same libraries + also underpin the data fetched and shown in the Flutter screen. + * iOS TODO. +* The platform application interfaces with the Flutter book details page using + idiomatic platform API conventions rather than Flutter conventions. + * On Android, the Flutter activity receives the book to show via activity + intent and returns the edited book by setting the result intent on the + activity. No Flutter concepts are leaked into the consumer activity. + * iOS TODO. +* The [pigeon](https://pub.dev/packages/pigeon) plugin is used to generate + interop APIs and data classes. The same `Book` model class is used within the + Kotlin/Swift program, the Dart program and in the interop between Kotlin/Swift + and Dart. diff --git a/add_to_app/flutter_module_books/lib/api.dart b/add_to_app/flutter_module_books/lib/api.dart new file mode 100644 index 000000000..80aea5bb4 --- /dev/null +++ b/add_to_app/flutter_module_books/lib/api.dart @@ -0,0 +1,100 @@ +// Autogenerated from Pigeon (v0.1.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import +import 'dart:async'; +import 'package:flutter/services.dart'; + +class Book { + String title; + String subtitle; + String author; + String description; + String publishDate; + int pageCount; + // ignore: unused_element + Map _toMap() { + final Map pigeonMap = {}; + pigeonMap['title'] = title; + pigeonMap['subtitle'] = subtitle; + pigeonMap['author'] = author; + pigeonMap['description'] = description; + pigeonMap['publishDate'] = publishDate; + pigeonMap['pageCount'] = pageCount; + return pigeonMap; + } + + // ignore: unused_element + static Book _fromMap(Map pigeonMap) { + final Book result = Book(); + result.title = pigeonMap['title']; + result.subtitle = pigeonMap['subtitle']; + result.author = pigeonMap['author']; + result.description = pigeonMap['description']; + result.publishDate = pigeonMap['publishDate']; + result.pageCount = pigeonMap['pageCount']; + return result; + } +} + +abstract class FlutterBookApi { + void displayBookDetails(Book arg); + static void setup(FlutterBookApi api) { + { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.FlutterBookApi.displayBookDetails', + StandardMessageCodec()); + channel.setMessageHandler((dynamic message) async { + final Map mapMessage = + message as Map; + final Book input = Book._fromMap(mapMessage); + api.displayBookDetails(input); + }); + } + } +} + +class HostBookApi { + Future cancel() async { + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.HostBookApi.cancel', StandardMessageCodec()); + + final Map replyMap = await channel.send(null); + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null); + } else if (replyMap['error'] != null) { + final Map error = replyMap['error']; + throw PlatformException( + code: error['code'], + message: error['message'], + details: error['details']); + } else { + // noop + } + } + + Future finishEditingBook(Book arg) async { + final Map requestMap = arg._toMap(); + const BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.HostBookApi.finishEditingBook', + StandardMessageCodec()); + + final Map replyMap = await channel.send(requestMap); + if (replyMap == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + details: null); + } else if (replyMap['error'] != null) { + final Map error = replyMap['error']; + throw PlatformException( + code: error['code'], + message: error['message'], + details: error['details']); + } else { + // noop + } + } +} diff --git a/add_to_app/flutter_module_books/lib/main.dart b/add_to_app/flutter_module_books/lib/main.dart new file mode 100644 index 000000000..2fcb782ec --- /dev/null +++ b/add_to_app/flutter_module_books/lib/main.dart @@ -0,0 +1,201 @@ +// Copyright 2020 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_module_books/api.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + primaryColor: const Color(0xff6200ee), + ), + home: BookDetail(), + ); + } +} + +typedef BookReceived = void Function(Book book); + +class FlutterBookApiHandler extends FlutterBookApi { + FlutterBookApiHandler(this.callback); + + final BookReceived callback; + + @override + void displayBookDetails(Book book) { + assert( + book != null, + 'Non-null book expected from FlutterBookApi.displayBookDetails call.', + ); + callback(book); + } +} + +class BookDetail extends StatefulWidget { + const BookDetail({this.hostApi, this.flutterApi}); + + // These are the outgoing and incoming APIs that are here for injection for + // tests. + final HostBookApi hostApi; + final FlutterBookApi flutterApi; + + @override + _BookDetailState createState() => _BookDetailState(); +} + +class _BookDetailState extends State { + Book book; + + HostBookApi hostApi; + + FocusNode textFocusNode = FocusNode(); + TextEditingController titleTextController = TextEditingController(); + TextEditingController subtitleTextController = TextEditingController(); + TextEditingController authorTextController = TextEditingController(); + + @override + void initState() { + super.initState(); + + // This `HostBookApi` class instance lets us make outgoing calls to the + // platform. + hostApi = widget.hostApi ?? HostBookApi(); + + // Registering this `FlutterBookApiHandler` class lets us receive incoming + // calls from the platform. + // TODO(gaaclarke): make the setup method an instance method so it's + // injectable https://github.com/flutter/flutter/issues/59119. + FlutterBookApi.setup(FlutterBookApiHandler( + // The `FlutterBookApi` just has one method. Just give a closure for that + // method to the handler class. + (Book book) { + setState(() { + // This book model is what we're going to return to Kotlin eventually. + // Keep it bound to the UI. + this.book = book; + titleTextController.text = book.title; + titleTextController.addListener(() { + this.book?.title = titleTextController.text; + }); + // Subtitle could be null. + // TODO(gaaclarke): https://github.com/flutter/flutter/issues/59118. + subtitleTextController.text = book.subtitle ?? ''; + subtitleTextController.addListener(() { + this.book?.subtitle = subtitleTextController.text; + }); + authorTextController.text = book.author; + authorTextController.addListener(() { + this.book?.author = authorTextController.text; + }); + }); + })); + } + + void clear() { + book = null; + // Keep focus if going to the home screen but unfocus if leaving + // the activity. + textFocusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Book Details'), + leading: IconButton( + icon: Icon(Icons.clear), + // Pressing clear cancels the edit and leaves the activity without + // modification. + onPressed: () { + hostApi.cancel(); + clear(); + }, + ), + actions: [ + IconButton( + icon: Icon(Icons.check), + // Pressing save sends the updated book to the platform. + onPressed: () { + hostApi.finishEditingBook(book); + clear(); + }, + ), + ], + ), + body: book == null + // Draw a spinner until the platform gives us the book to show details + // for. + ? Center(child: CircularProgressIndicator()) + : Focus( + focusNode: textFocusNode, + child: ListView( + padding: EdgeInsets.all(24), + children: [ + TextField( + controller: titleTextController, + decoration: InputDecoration( + border: OutlineInputBorder(), + filled: true, + hintText: "Title", + labelText: "Title", + ), + ), + SizedBox(height: 24), + TextField( + controller: subtitleTextController, + maxLines: 2, + decoration: InputDecoration( + border: OutlineInputBorder(), + filled: true, + hintText: "Subtitle", + labelText: "Subtitle", + ), + ), + SizedBox(height: 24), + TextField( + controller: authorTextController, + decoration: InputDecoration( + border: OutlineInputBorder(), + filled: true, + hintText: "Author", + labelText: "Author", + ), + ), + SizedBox(height: 32), + Divider(), + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '${book.pageCount} pages ~ published ${book.publishDate}'), + ), + ), + Divider(), + SizedBox(height: 32), + Center( + child: Text( + 'BOOK DESCRIPTION', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + decoration: TextDecoration.underline, + ), + ), + ), + SizedBox(height: 12), + Text( + book.description, + style: TextStyle(color: Colors.grey.shade600, height: 1.24), + ), + ], + ), + ), + ); + } +} diff --git a/add_to_app/flutter_module_books/pigeon/schema.dart b/add_to_app/flutter_module_books/pigeon/schema.dart new file mode 100644 index 000000000..4aefde867 --- /dev/null +++ b/add_to_app/flutter_module_books/pigeon/schema.dart @@ -0,0 +1,32 @@ +// Copyright 2020 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:pigeon/pigeon.dart'; + +class Book { + String title; + String subtitle; + String author; + String description; + String publishDate; + int pageCount; + // Thumbnail thumbnail; +} + +// TODO(gaaclarke): add this back when the https://github.com/flutter/flutter/issues/58896 +// crash is resolved. +// class Thumbnail { +// String url; +// } + +@FlutterApi() +abstract class FlutterBookApi { + void displayBookDetails(Book book); +} + +@HostApi() +abstract class HostBookApi { + void cancel(); + void finishEditingBook(Book book); +} diff --git a/add_to_app/flutter_module_books/pubspec.lock b/add_to_app/flutter_module_books/pubspec.lock new file mode 100644 index 000000000..d8567dcb3 --- /dev/null +++ b/add_to_app/flutter_module_books/pubspec.lock @@ -0,0 +1,160 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.4.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.1" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.14.12" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.6" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.8" + mockito: + dependency: "direct dev" + description: + name: mockito + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.1" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + pigeon: + dependency: "direct dev" + description: + name: pigeon + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.9.3" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.5" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.16" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.6" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.8" +sdks: + dart: ">=2.7.0 <3.0.0" diff --git a/add_to_app/flutter_module_books/pubspec.yaml b/add_to_app/flutter_module_books/pubspec.yaml new file mode 100644 index 000000000..4594ca196 --- /dev/null +++ b/add_to_app/flutter_module_books/pubspec.yaml @@ -0,0 +1,33 @@ +name: flutter_module_books +description: A Flutter module using the Pigeon package to demonstrate + integrating Flutter in a realistic scenario where the existing platform app + already has business logic and middleware constraints. + +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + pigeon: ^0.1.0 + mockito: ^4.1.1 + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + + # This section identifies your Flutter project as a module meant for + # embedding in a native host app. These identifiers should _not_ ordinarily + # be changed after generation - they are used to ensure that the tooling can + # maintain consistency when adding or modifying assets and plugins. + # They also do not have any bearing on your native host application's + # identifiers, which may be completely independent or the same as these. + module: + androidX: true + androidPackage: dev.flutter.example.flutter_module_books + iosBundleIdentifier: dev.flutter.example.flutterModuleBooks \ No newline at end of file diff --git a/add_to_app/flutter_module_books/test/widget_test.dart b/add_to_app/flutter_module_books/test/widget_test.dart new file mode 100644 index 000000000..6f694de7c --- /dev/null +++ b/add_to_app/flutter_module_books/test/widget_test.dart @@ -0,0 +1,43 @@ +// Copyright 2020 The Flutter team. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_module_books/api.dart'; +import 'package:flutter_module_books/main.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + testWidgets('Pressing clear calls the cancel API', + (WidgetTester tester) async { + MockHostBookApi mockHostApi = MockHostBookApi(); + + await tester.pumpWidget( + MaterialApp( + home: BookDetail(hostApi: mockHostApi), + ), + ); + + await tester.tap(find.byIcon(Icons.clear)); + + verify(mockHostApi.cancel()); + }); + + testWidgets('Pressing done calls the finish editing API', + (WidgetTester tester) async { + MockHostBookApi mockHostApi = MockHostBookApi(); + + await tester.pumpWidget( + MaterialApp( + home: BookDetail(hostApi: mockHostApi), + ), + ); + + await tester.tap(find.byIcon(Icons.check)); + + verify(mockHostApi.finishEditingBook(any)); + }); +} + +class MockHostBookApi extends Mock implements HostBookApi {} diff --git a/tool/travis_flutter_script.sh b/tool/travis_flutter_script.sh index eff070770..757590312 100755 --- a/tool/travis_flutter_script.sh +++ b/tool/travis_flutter_script.sh @@ -28,6 +28,7 @@ echo "Flutter SDK found at ${LOCAL_SDK_PATH}" declare -ar PROJECT_NAMES=( "add_to_app/flutter_module" \ "add_to_app/flutter_module_using_plugin" \ + "add_to_app/flutter_module_books" \ "animations" \ "flutter_maps_firestore" \ "infinite_list" \