Post date: May 03, 2018 4:32:53 PM
Some months ago, I decided to build a free and open-source CLI (command-line interface) password manager called go-hash using Go. It works great on desktop. The interface is easy to learn and I believe a password manager is a perfect fit for a CLI application (most password manager's GUIs I've used are ugly and just take unnecessary screen real-estate).
However, CLIs work well only on desktops (and thanks to Go's compiler, go-hash works on Windows, Mac and Linux seamlessly)... on mobile, that's another story. I couldn't find an easy way to run a terminal and install native applications on mobiles (actually, there is Termux for Android, and probably Termius would work on iPhones, but I am not sure I would actually enjoy using the terminal on my phone!). So, I decided to build a tiny mobile app for go-hash.
The problem is that, unfortunately, I have no experience writing apps for either Android or iPhone... and although I work as a Java/Kotlin developer, I've never used the Android SDK... and never done any Objective-C or Swift at all, let alone work with the iOS GUI framework - so the idea of learning all of it just to write a little app looked like far too much work.
That's why I decided to look into Flutter, which is advertised as "Google’s mobile app SDK for crafting high-quality native interfaces on iOS and Android in record time.". Sounds like just what I needed! Instead of writing 2 separate applications, one for each platform, Flutter makes it possible to write a single application in Dart (which is quite easy to learn for anyone who knows Java, JavaScript or Swift) that runs on both mobile platforms.
In this blog post, I will show how an existing Go application (as we'll see, with some adaptations) can be used from Flutter, so you can write the GUI side of the application in Dart (instead of one for Android, one for iOS), and the "back-end" mostly in Go (rather than duplicate the logic in Java/Kotlin and Objective-C/Swift), with only a thin glue-code layer written in the platform's native language (in our case, Kotlin on Android, Swift on iOS).
After installing Flutter and running flutter doctor, I got this report:
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel beta, v0.3.1, on Mac OS X 10.13.2 17C205, locale en-US)
[✗] Android toolchain - develop for Android devices
✗ Unable to locate Android SDK.
Install Android Studio from: https://developer.android.com/studio/index.html
On first launch it will assist you in installing the Android SDK components.
(or visit https://flutter.io/setup/#android-setup for detailed instructions).
If Android SDK has been installed to a custom location, set $ANDROID_HOME to that location.
[!] iOS toolchain - develop for iOS devices
✗ Xcode installation is incomplete; a full installation is necessary for iOS development.
Download at: https://developer.apple.com/xcode/download/
Or install Xcode via the App Store.
Once installed, run:
sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer
✗ Brew not installed; use this to install tools for iOS device development.
Download brew at https://brew.sh/.
[✗] Android Studio (not installed)
[!] Connected devices
! No devices available
! Doctor found issues in 4 categories.
This is basically saying that you need to have the Android and iOS developer toolchain installed for Flutter to work. Installing all of this stuff (IDEs, phone emulators, CLI tools, package managers) takes time but is necessary for everything else to work later, so try to follow exactly the instructions shown on the Flutter website for your platform before proceeding.
After going through the whole setup process, I ran flutter doctor again a few times, fixing the errors it reported as I went (if you have trouble with Python's six dependency, see this), and after a few tries, everything seemed to be finally installed:
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel beta, v0.3.1, on Mac OS X 10.13.2 17C205, locale en-US)
[✓] Android toolchain - develop for Android devices (Android SDK 27.0.3)
[✓] iOS toolchain - develop for iOS devices (Xcode 9.3)
[✓] Android Studio (version 3.1)
[✓] Connected devices (1 available)
• No issues found!
As we've just installed Android Studio (which is based on the IntelliJ platform), the next step is to to follow the Configure Editor guide, which describes how to install the Dart and Flutter plugins on Android Studio (or, if you prefer, VS Code - but this blog post uses only Android Studio).
Finally, we get to the Test Drive section of the Flutter guide, which is where we get to do more interesting things.
You can create a new app (named gohash in this case) by using the flutter command as follows:
flutter create -i swift -a kotlin gohash
The -i swift option tells Flutter to use Swift, not Objective-C, as the iOS language, and the -a kotlin option tells it to use Kotlin, not Java, as the Android language.
If everything worked correctly, we're ready to run the default app created by the Flutter plugin on the device emulator installed in the previous steps and even on a real device, if one happens to be connected.
Here's what the Android Studio screen looked like for me at this stage:
Trying to run the application with the play button (in the top toolbar) resulted in an error, but the error message told me what to do to fix:
Finished with error:
* Error running Gradle:
Unable to download needed Android SDK components, as the following licenses have not been accepted:
Android SDK Build-Tools 26.0.2
To resolve this, please run the following command in a Terminal:
flutter doctor --android-licenses
After running the magic command shown above and blindly accepting all Android licenses, I pressed the Play button again, and this time it worked! Except the application started running on my real phone, not the emulator as I had expected :). But that was a nice surprise!
I changed the values of some Strings in the code, saved it, and almost immediately, the app on the phone changed accordingly to reflect my changes. Pretty impressive!
To run the app in the phone emulator, so you don't have to keep the real phone tethered to the laptop all the time, just change the target device (the device emulator must be running to be shown in the drop-down):
Now, the Android emulator opens the demo app instead:
After starting up the iOS Simulator (open -a Simulator on Mac), we can also open our app on the emulated iPhone (notice that you can also run the app from the command line with flutter run at the root of the project):
Hot reload works both in the emulators and in the real phone: change the code, save it, and the changes automatically reflected on the running app.
Now that we've seen how to get a simple Flutter application running, it's time to find a way to use the Go code we're interested in from Flutter. The problem is that Go generates native code for the phone's chip architecture, and the integration with native code must be implemented in both Android and iOS via each platform's native bindings.
For this reason, we have to do two main things: first, generate bindings for Android and iOS (using the gomobile tool) to make the Go code available to Java/Kotlin and Objective-C/Swift, respectively... second, implement a Flutter Plugin which can, as we'll see, call the platform-specific code.
But let's start by creating a simple Fluter Plugin that illustrates how we can call platform-specific APIs using a single, common Dart API. After that, we can implement the iOS and Android glue code that calls the Go library via generated bindings. Finally, we'll modify the Flutter Plugin to expose the Go library to Dart code, so it can be consumed uniformly by the Flutter mobile app.
Flutter uses platform channels in order to integrate with platform-specific APIs. Even though it is possible to use them within the mobile app code base, doing that is messy because it mixes the mobile app's Flutter code with the glue-code required to call the Go bindings in both Android and iOS, making things more complex than they should be.
By writing a Flutter Plugin instead, we clearly separate the mobile app's code from the Go application bindings, which in the end gets exposed as just another Flutter package.
To create a Fluter Plugin is very easy. You can use Android Studio's wizard via File -> New -> New Flutter Project..., Select Flutter Plugin, then follow the prompts. Alternatively, just type this command in the command-line (the last argument is the name of the package you want to create):
flutter create -t plugin -i swift -a kotlin gohash_mobile
In the Android Studio Wizard, to use Kotlin and Swift, select the check-boxes as shown below:
If you used the command-line to create the project, open it in Android Studio to continue following along.
You should see something like this:
You can see a Dart package is created under the lib directory. It simply exposes a variable called platformVersion which uses a MethodChannel to invoke the getPlatformVersion method in platform-specific code. The Android implementation can be found under android/src/main/kotlin/, the iOS code under ios/Classes/.
If you want, you can actually run the example app Flutter generated under the example/ folder to make sure everything is working under both iOS and Android!
Hint: to edit the iOS code using XCode, first build the project, then open it on XCode using these commands (as explained in this Youtube video):
cd example/
flutter build ios
open ios/Runner.xcworkspace/
The plugin code looks like this, so far:
import 'dart:async';
import 'package:flutter/services.dart';
class GohashMobile {
static const MethodChannel _channel =
const MethodChannel('gohash_mobile');
static Future<String> get platformVersion async {
final String version = await _channel.invokeMethod('getPlatformVersion');
return version;
}
}
lib/gohash_mobile.dart
ios/Classes/SwiftGohashMobilePlugin.swift
package com.example.gohashmobile
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.PluginRegistry.Registrar
class GohashMobilePlugin(): MethodCallHandler {
companion object {
@JvmStatic
fun registerWith(registrar: Registrar): Unit {
val channel = MethodChannel(registrar.messenger(), "gohash_mobile")
channel.setMethodCallHandler(GohashMobilePlugin())
}
}
override fun onMethodCall(call: MethodCall, result: Result): Unit {
if (call.method.equals("getPlatformVersion")) {
result.success("Android ${android.os.Build.VERSION.RELEASE}")
} else {
result.notImplemented()
}
}
}
android/src/main/kotlin/com/example/gohashmobile/GohashMobilePlugin.kt
This is just sample code to make it easier for us to get started. We need to replace it with the Dart API which will wrap the Go API we'll get via the generated Go bindings.
We'll come back to the Dart API later. First, let's create the Go bindings for each platform.
The gomobile tool can be used to create all-Go mobile apps, or to just generate bindings for Android (Java/Kotlin) and iOS (Objective-C/Swift) apps. In our case, as the idea is to use Flutter for the GUI, we'll just generate bindings so we can call the Go code from Flutter.
But for the gomobile tool to work, we first need to install the Android NDK (Native Development Kit). Using Android Studio, install the LLDB, CMake and NDK components as explained in the Getting Started with NDK guide.
After go-getting gomobile, run gomobile init pointing it to the NDK... on my Mac, the command looked like this:
gomobile init -ndk ~/Library/Android/sdk/ndk-bundle/
With both gomobile and the NDK installed, we should now be able to build the bindings for both iOS and Android.
The command to create iOS bindings for a Go package is the following (the last argument is the full name of the Go package you want to use):
gomobile bind -target=ios github.com/renatoathaydes/go-hash
First time I tried running gomobile, I found out that it's not possible to use a main Go package:
> Task :go:gobind
/Users/renato/go/bin/gomobile: binding 'main' package () is not supported
I did not design go-hash as a library, unfortunately, so I had to move some Go code around to make the functions I needed to use public in a non-main Go package. Once that was done, I tried again, just to get another error:
/Users/renato/go/bin/gomobile: go build -i -buildmode=c-shared -o=/var/folders/vz/dnr6kc0d4z76tk0mjxdbswxh0000gp/T/gomobile-work-518425925/android/src/main/jniLibs/armeabi-v7a/libgojni.so gobind failed: exit status 1
go build internal/race: mkdir /usr/local/go/pkg/android_arm_shared: permission denied
go build errors: mkdir /usr/local/go/pkg/android_arm_shared/: permission denied
go build runtime/internal/sys: mkdir /usr/local/go/pkg/android_arm_shared: permission denied
go build unicode/utf8: mkdir /usr/local/go/pkg/android_arm_shared: permission denied
go build sync/atomic: mkdir /usr/local/go/pkg/android_arm_shared: permission denied
go build unicode/utf16: open /usr/local/go/pkg/android_arm_shared/unicode/utf16.a: no such file or directory
go build unicode: open /usr/local/go/pkg/android_arm_shared/unicode.a: no such file or directory
go build math: open /usr/local/go/pkg/android_arm_shared/math.a: no such file or directory
go build runtime/cgo: mkdir /usr/local/go/pkg/android_arm_shared: permission denied
Hm... it seems that Go needs to create new directories under /usr/local/go, so I had to change the owner of this directory to avoid having to build with sudo (is there a better way to solve this??):
chown -R renato /usr/local/go
After this, I was hit with the type limitations of gomobile (not all Go code is supported), which meant I needed to make even more changes to the Go code to workaround those limitations. For example, here's the errors I got initially:
gomobile: /Users/renato/go/bin/gobind -lang=go,objc -outdir=/var/folders/vz/dnr6kc0d4z76tk0mjxdbswxh0000gp/T/gomobile-work-805394183 -tags=ios github.com/renatoathaydes/go-hash/gohash_db failed: exit status 1
unsupported, named type github.com/renatoathaydes/go-hash/gohash_db.State
unsupported, named type github.com/renatoathaydes/go-hash/gohash_db.State
unsupported const type uint8 for Argon2Threads
unsupported, named type github.com/renatoathaydes/go-hash/gohash_db.State
Long story short: the type limitations imposed by gomobile are still very crippling. time.Time is not supported, collections (such as map and slices) are not supported, uint8 (or any unsigned type) is not supported... struct return-values are supported only if returning a pointer (i.e. *MyStruct rather than MyStruct). So basically no Go package can be supported as-is.
For that reason, the only way to get around this problem is to create a (hopefully small) Go package exposing just enough API for the mobile app to function! The API cannot include any of the things mentioned above.
The biggest limitation is definitely lack of support for collections. I worked around that limitation by creating iterator types for the collections I wanted to expose.
For example, instead of exposing a function like this (won't work as it return a slice):
func Read(filepath string) ([]LoginInfo, error)
You can return an iterator instead (notice that there's no limit to non-exported symbol's types, hence it's ok to have slices inside private struct fields):
type LoginInfoIterator struct {
contents []LoginInfo
currentIndex uint
}
func (iter *LoginInfoIterator) Next() *LoginInfo {
if iter.currentIndex < len(iter.contents) {
item := iter.contents[iter.currentIndex]
iter.currentIndex++
return &item
}
return nil
}
func Read(filepath string) (*LoginInfoIterator, error) {
// TODO
}
Once you have a compliant Go API, you can try to generate bindings for it again:
gomobile bind -target=ios github.com/renatoathaydes/go-hash/mobileapi
Even if the command appears to work, you must inspect the resulting bindings to see if there was any function or fields that were "skipped" due to type violations. For example, I had noticed these problems in my early attempts:
// skipped field LoginInfo.UpdatedAt with unsupported type: time.Time
...
// skipped field State.Data with unsupported type: map[string][]github.com/renatoathaydes/go-hash/gohash_db.LoginInfo
...
// skipped function ReadDatabase with unsupported parameter or return types
Make sure the functions and types you need to use were not skipped before proceeding.
To include the Go bindings in the iOS project, you can simply drag-and-drop the Mobileapi.framework/ directory created by gomobile onto the XCode project tree, as explained here. After doing that, you can edit the Swift code in XCode and it will recognize the Mobileapi library (or whatever the name of your own library).
To make sure that Mobileapi.framework is available to users of the Flutter Plugin later, you also need to save it under the ios/Frameworks/ directory, then add the following line to the ios/gohash_mobile.podspec file:
s.ios.vendored_frameworks = 'Frameworks/Mobileapi.framework'
Assuming you successfully generated bindings for iOS, you have already made the necessary changes to your Go package to get gomobile to work properly. If you haven't done that, you should go back and read the previous section as you will most likely need to prepare your Go package (or even create a new package just for consumption by the mobile apps) before you can successfully generate bindings for it.
Even though we'll see a way to use Gradle to build the Go bindings, you can use this command to generate bindings for Android is the same as for iOS, just replace the -target=ios option with -target=android:
gomobile bind -target=android github.com/renatoathaydes/go-hash/mobileapi
This will generate 2 files: a jar containing the Java bindings' sources, and an AAR (Android Archive). You should inspect the generated sources to make sure all methods and types you want to use were correctly generated and not "skipped" (see the previous section for details).
To include the AAR in the Android project, open the android/ folder in another Android Studio window (so the project is recognized as an Android/Kotlin project), then follow these instructions. After doing that, you should be able to import the Go package's bindings from the Kotlin code, as we'll see in the next steps.
However, the recommended way to generate bindings for Android is to use Gradle to do it, so that every time the project is built, the bindings are generated again (the Gradle plugin should only re-generate the bindings if necessary, but currently it seems to not support that yet).
To do that, first create a new directory at android/mobileapi, then copy this build.gradle file into it:
plugins {
id "org.golang.mobile.bind" version "0.2.11"
}
gobind {
// the identifier of the Go package to be used
pkg = "github.com/renatoathaydes/go-hash/mobileapi"
/* GOPATH where the Go package is; check `go env` */
GOPATH = System.getProperty('user.home') + "/go"
/* Absolute path to the go binary */
// GO = "/PATH/TO/GO"
/* Optionally, set the absolute path to the gomobile binary if the
/* gomobile binary is not located in the GOPATH's bin directory. */
// GOMOBILE = "/PATH/TO/GOMOBILE"
}
android/mobileapi/build.gradle
Now, include this sub-project in the build:
rootProject.name = 'gohash_mobile'
include ':mobileapi'
// dir must be set explicitly so this file can be included in the example app build.
project(':mobileapi').projectDir = file('mobileapi')
android/settings.gradle
This should be all you need to do to generate the bindings automatically, but due to this bug, Android Studio may not be able to "sync" the project, and you won't be able to see the Go bindings from the Android code. Also, due this other bug, you'll have to make a few other changes to the Gradle setup for the example app to be able to run.
See this commit for fixes for the former bug, and this one for the latter.
Now that we generated the Go bindings for iOS and included the generated library in the iOS project, we can use it from the Swift code. The generated bindings are written in Objective-C, but calling Objective-C from Swift is straightforward.
The Objective-C types we want to move from iOS (and later, Android) to Dart via platform channels must either be automatically serializable (e.g. null, bool, nums, String, Lists, Maps) or have custom serializers (which we must write ourselves), as described in the StandardMessageCoded documentation. But notice that if we use custom serializers, we need to write them for both iOS and Android, which can be costly. A simpler approach is to convert the native types, if needed, into the simple types which are automatically serializable.
For example, I decided to write the following Swift function to convert the go-hash database into a [String: [[String: Any]]] instance, which can be serialized automatically. This way, the same function can easily be implemented in Kotlin to produce the equivalent Map<String, List<Map<String, Any>>> instance. On the Dart side, we can create a simple model which can be built from the same structure regardless of whether it comes from Swift or Kotlin (similarly to if we had a JSON object as input).
Here's the conversion function in Swift:
And the updated implementation of the handle function of the Swift FlutterPlugin implementation:
This is all the Swift code we'll ever need to write! Or at least until we need to support more method calls, but for now, that's it.
Similarly to the previous section, we need to implement the MethodCallHandler in order to delegate method calls to the Go package. gomobile generates Java bindings, but just like we were able to call the Objectve-C bindings from Swift in iOS, we can also easily call Java from Kotlin without limitations.
In this example, we need to produce an instance of Map<String, List<Map<String, Any>>> on calls to the getDb method, as we did in Swift. The Go bindings produce an actual Database object, so we need to convert that to the expected format:
private fun androidDatabaseFrom(db: Database): Map<String, List<Map<String, Any>>> {
val result = HashMap<String, List<Map<String, Any>>>()
val iterator = db.iter()
do {
val item = iterator.next()?.apply {
val entries = mutableListOf<Map<String, Any>>()
result[group] = entries
do {
val entry = next()?.apply {
val loginInfo = mapOf<String, Any>(
"name" to name(),
"username" to username(),
"password" to password(),
"url" to url(),
"description" to description(),
"updatedAt" to updatedAt())
entries.add(loginInfo)
}
} while (entry != null)
}
} while (item != null)
return result
}
Now we can easily write an implementation for the onMethodCall method (using a small helper function):
private inline fun <reified T> readArgument(args: List<*>, index: Int): T {
if (index < args.size) {
val argument = args[index]
if (argument is T) {
return argument
} else {
throw IllegalArgumentException("Argument at index $index " +
"has unexpected type: ${argument?.javaClass?.name}")
}
}
throw IllegalArgumentException("No argument available at index $index")
}
override fun onMethodCall(call: MethodCall, result: Result) {
if (call.method == "getDb") {
val args = call.arguments
if (args is List<*>) {
if (args.size == 2) {
try {
val dbPath = readArgument<String>(args, 0)
val password = readArgument<String>(args, 1)
val db = Mobileapi.readDatabase(dbPath, password)
result.success(androidDatabaseFrom(db))
} catch (e: IllegalArgumentException) {
result.error("BAD_ARGS", e.message!!, null)
} catch (e: Exception) {
result.error("NATIVE_ERR", e.message!!, null)
}
} else {
result.error("BAD_ARGS",
"Wrong arg count (getDb expects 2 args): ${args.size}", null)
}
} else {
result.error("BAD_ARGS", "Wrong argument types", null)
}
} else {
result.notImplemented()
}
}
Notice that this code is an almost-exact translation of the Swift code we wrote earlier (and looks like Swift has a slightly cleaner syntax than Kotlin, based on this small sample alone).
Both iOS and Android now have identical implementations of the getDb method call, which uses the native Go bindings to do the actual work... and which we can use in Dart via a method channel. Both implementations return a type that in Dart looks like this:
Map<String, List<Map<String, dynamic>>>
However, it appears that generic objects that go through method channels lose the generic type arguments, hence we end up getting an instance of this instead:
Map<dynamic, dynamic>
That's, of course, not very convenient. It would be a lot nicer to expose a typed model for this on the Dart side.
For this reason, I decided to create conversion functions to go from Map<..> to GoHashDb, which will be the type we'll expose in the API of our Flutter Plugin. Here's the full Dart implementation of the plugin:
import 'dart:async';
import 'package:flutter/services.dart';
class GohashMobile {
static const MethodChannel _channel = const MethodChannel('gohash_mobile');
static Future<GohashDb> getDb(String filePath, String password) async {
final Map<dynamic, dynamic> db =
await _channel.invokeMethod('getDb', [filePath, password]);
return GohashDb.from(filePath, db);
}
}
DateTime _timestamp(dynamic value) =>
new DateTime.fromMillisecondsSinceEpoch(value as int);
class GohashDb {
final String filePath;
final List<Group> groups;
const GohashDb(this.filePath, this.groups);
static GohashDb from(String filePath, Map<dynamic, dynamic> map) {
List<Group> groups = new List<Group>();
map.forEach((key, contents) {
List<dynamic> contentList = contents;
var entries = contentList.map((e) => LoginInfo.from(e)).toList();
groups.add(new Group(key.toString(), entries));
});
return new GohashDb(filePath, groups);
}
}
class Group {
final String name;
final List<LoginInfo> entries;
const Group(this.name, this.entries);
}
class LoginInfo {
final String name, description, username, password, url;
final DateTime updatedAt;
const LoginInfo(this.name, this.description, this.username, this.password,
this.url, this.updatedAt);
LoginInfo.from(Map<dynamic, dynamic> map)
: name = map["name"] as String,
description = map['description'] as String,
username = map['username'] as String,
password = map['password'] as String,
url = map['url'] as String,
updatedAt = _timestamp(map['updatedAt']);
}
lib/gohash_mobile.dart
Hope we can agree that, by looking at this code sample, Dart is a really nice language to read (and I can tell it was very easy to write, even being new to Dart)! It's in my opinion, almost as nice as Kotlin or Swift to work with. Almost because the type-safety is much loser (took me a few trial-and-error attempts to get the types above right - seems that this is due to the fact that we get dynamic objects from method channels, which bypass type-safety entirely, so you get runtime errors rather than compile-time errors if you try to pass dynamic objects into a function with the wrong type) and it does not seem to offer protection against null-dereferencing (which bit me a few times), though there's a proposal to fix that.
Flutter Plugins by default include an example app that can be used for tests. I changed the example app to verify the plugin was working correctly, you can check my changes in this commit. But we'll skip that here so we can move on to the actual mobile application.
Once we have a Flutter Plugin, we need to make sure to publish it so others can use it! As you might have guessed, the flutter tool can be used to do that.
Use this command to run a dry-run and make sure it all looks good:
flutter packages pub publish --dry-run
All files not ignored by git will be published. It seems to be common practice to include even the example app. It gets shown on the Flutter Plugin docs, so it's really easy for users to see how they can use your plugin. In our case, we also need to include the iOS Mobileapi.framework library, though on Android, if you followed the advice to let Gradle run gomobile, Gradle itself will generate the bindings and include it in the Android build when users of the Flutter Plugin import this package. That also means that users of the plugin will need to have Go installed. To avoid that, it may be better to just publish the aar file with the project and not use Gradle to generate it - either way seems fine depending on the situation.
To actually publish the package, run the command:
flutter packages pub publish
Interestingly, all you need to be able to publish a package is to be logged in to your Google account on the browser! The CLI will give you a link that you can open in the browser to get a token for publishing the package. Pretty cool.
Finally, we can go back to the mobile app we'd created at the beginning of this article. Now, we can use the Flutter Plugin from the previous section as just any Flutter package!
Open the pubspec.yaml file. In the dependencies section, add the Flutter plugin (I named the Flutter Plugin gohash_mobile and gave it version 0.0.1), so your dependencies should look something like this:
dependencies:
gohash_mobile: ^0.0.2
flutter:
sdk: flutter
Every time you add/remove dependencies, you can run this command to let Flutter update its cache and config files:
flutter packages get
To get the Android Gradle build to work, you'll have to make some changes to work around the bug we met earlier.
Add this line at the top of the android/settings.gradle file (where mobileapi is the name of the Go-bindings sub-project in the Flutter Plugin we've created):
include ':mobileapi'
Add this as the very last line of the same file:
project(':mobileapi').projectDir = project(':gohash_mobile').file('mobileapi')
Once again, notice that these steps shouldn't be necessary, hopefully the Flutter team will fix the problem before the 1.0 release!
Now, the Android build should run without errors:
flutter build apk
To build in iOS, make sure to open the project in XCode first, select a Signing Team, then run the project from XCode itself before trying from Android Studio or the flutter CLI (not sure why, but it seems XCode creates some necessary config files). After that, you should be able to run this command successfully:
flutter build ios
The last thing to do is, of course, consume the Flutter Plugin API and create the actual mobile app's GUI for it. That's the fun part!
This is what I wanted my UI to look like (except it should not look like it was drawn by a 5-year-old!):
And even if it's still early stages, the app turned out pretty good-looking IMHO (for my first mobile app ever):
This is the relevant UI code (from lib/main.dart) that created the above screenshot:
_showEntry(LoginInfo loginInfo) {
showDialog(
context: context,
builder: (ctx) => SimpleDialog(
contentPadding: EdgeInsets.all(10.0),
children: [
Text('Username:', style: _boldFont),
Text(loginInfo.username),
Text('URL:', style: _boldFont),
Text(loginInfo.url),
Text('Last changed:', style: _boldFont),
Text("${loginInfo.updatedAt}"),
Text('Description:', style: _boldFont),
Text(loginInfo.description),
],
));
}
Widget _buildCopierIcon(IconData icon, String value) {
return GestureDetector(
onTap: () => Clipboard.setData(ClipboardData(text: value)),
child:
Container(padding: EdgeInsets.only(left: 10.0), child: Icon(icon)));
}
Widget buildEntry(LoginInfo loginInfo) {
return ListTile(
trailing: Icon(Icons.description),
title: Row(children: [
Text(loginInfo.name),
_buildCopierIcon(Icons.person, loginInfo.username),
_buildCopierIcon(Icons.vpn_key, loginInfo.password),
]),
onTap: () => _showEntry(loginInfo));
}
Widget _buildGroupBody(Group group) {
return Column(children: group.entries.map(buildEntry).toList());
}
ExpansionPanel _buildGroup(int index, Group group) {
return ExpansionPanel(
headerBuilder: (ctx, isExpanded) => Text(
group.name,
textAlign: TextAlign.start,
style: _biggerFont,
),
isExpanded: index == _selectedGroupIndex,
body: _buildGroupBody(group));
}
Widget _buildGroups() {
final panels = _database.groups
.asMap()
.map((index, group) => MapEntry(index, _buildGroup(index, group)));
return ExpansionPanelList(
children: panels.values.toList(),
expansionCallback: (index, isExpanded) =>
setState(() => _selectedGroupIndex = index));
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('go-hash')),
body: _errorMessage.isEmpty
? SingleChildScrollView(
child: SafeArea(
child: Material(
child: _buildGroups(),
),
),
)
: Center(
child: Text("Error: $_errorMessage",
style: _biggerFont.apply(color: Colors.red))),
));
}
Dart and Flutter are very pleasurable to work with. Both the language and the UI widgets are very powerful, but still easy to learn. The fact that you can change nearly any code in the app and immediately see what happens on the UI, thanks to the hot-reloading feature, makes it possible to write the app interactively. That's a real boost to productivity.
I've worked or played around with quite a few UI frameworks before, from Java's Swing to JavaFX, GWT to React.js and Elm, and I believe that Flutter was clearly inspired by what came before it, and takes some great ideas from them.
Interacting with iOS- and Android-specific libraries and APIs, on the other hand, is not very nice. Platform channels work ok for simple cases, but as we've seen, passing generic types around, for example, exposes some nasty edges in the implementation.
Including native libraries (in our case, Go libs, but could as well be C/C++ or even Rust) with Flutter Plugins seems to be an area that still needs improvements, but with a little effort, things can already work, as they did for me... my main difficulty was actually having to learn the intricacies of embedding native libs in both Android and iOS, given that Flutter can't really do much to hide that away from Flutter developers.
The big let-down in this story, however, was gomobile. Even though the tool looks so promising, it's clearly not ready for serious usage yet - and given the low activity in the Github repo lately, looks like Google is not at all prioritizing developing it further. This decision does make sense, given they'd rather have people write the full mobile app code in Dart. But given the wealth of Go libraries available, it would be nice to make this integration work better in the future - that might actually help drive some more usage of Flutter in the future, who knows!?
But the one thing that's clear to me is that Flutter is here to stay, and it's already being used quite heavily (just look at the activity on the Gitter channel) even before it reached 1.0! Once it gets there, my bet is that it will easily become a main player in the mobile apps field.
gohash_mobile Flutter Plugin on GitHub
gohash_mobile_app on GitHub (this is the actual app - notice it's not ready yet! I need to finish the UI and some integrations like with DropBox so it actually becomes useful!)