How to get all GitHub issues using GraphQL

Issue #393

https://developer.github.com/v4/explorer/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
query { 
repository(owner: "onmyway133", name: "blog") {
issues(orderBy: {field: UPDATED_AT, direction: ASC}, last: 100) {
edges {
cursor
node {
title
createdAt
updatedAt
labels(first: 10) {
edges {
node {
name
}
}
}
}
}
}
}
}

In node.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const GraphQL = require('graphql-request')
const GraphQLClient = GraphQL.GraphQLClient

const client = new GraphQLClient('https://api.github.com/graphql', {
headers: {
Authorization: 'Bearer 123456730712334152e6e1232c53987654312',
},
})

const query = `{
repository(owner: "onmyway133", name: "blog") {
issues(first: 100) {
edges {
node {
title
createdAt
updatedAt
labels(first: 10) {
edges {
node {
name
}
}
}
}
}
}
}
}`

client.request(query)
.then(data => {
const issues = data.repository.issues.edges.map((edge) => { return edge.node })
console.log(issues)
})

How to use Hexo to deploy static site

Issue #392

It’s been a long journey since https://github.com/onmyway133/blog/issues/1, next step is to keep GitHub issues as source, and mirror those to a static site.

Use 2 repos

1
2
3
4
npm install -g hexo-cli
echo $PATH=$PATH:/Users/khoa/.nodenv/versions/10.15.2/bin/hexo
hexo init blog
npm install hexo-deployer-git --save

Update _config.yml

1
2
3
4
deploy:
type: git
repo: https://github.com/onmyway133/onmyway133.github.io.git
branch: master
1
2
hexo clean
hexo deploy

Read more

How to use type coersion in Javascript

Issue #391

People who make fun of Javascript probably don’t understand implicit type coersion and when to use triple equal. Javascript is very unexpected, but when we work with this language, we need to be aware.

  1. Coercion–Automatically changing a value from one type to another.
  2. If x is Number and y is String, return x == ToNumber(y)
  3. If x is String or Number and y is Object, return x == ToPrimitive(y)
  4. Empty array becomes empty string

Read more

How to safely access deeply nested object in Javascript

Issue #390

An object ‘s property can be null or undefined.

Accessing step by step is tedious

1
2
3
4
props.user &&
props.user.posts &&
props.user.posts[0] &&
props.user.posts[0].comments

Dynamic parsing path is too clever and involves string in the end, which is a no no

1
2
3
4
const get = (p, o) =>
p.reduce((xs, x) => (xs && xs[x]) ? xs[x] : null, o)

const getUserComments = get(['user', 'posts', 0, 'comments'])

Instead let’s use function and catch errors explicitly, and defaults with a fallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const get: (f, defaultValue) => {
try {
const value = f()
if (isNotNullOrUndefined(value)) {
return value
} else {
return defaultValue
}
} catch {
return defaultValue
}
}

const comments = get(() => { .user.posts[0].comments }, [])

Read more

How to use flow type in Javascript

Issue #389

Prefer flow over TypeScript for simplicity

Javascript primitive types number and string are too general and do not express the domain objects. Because lack of type alias in Javascript, we can use flow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export type Centimeter = number
export type Color = string
export type ImageSource = number
export type Kilogram = number
export type Kilocalorie = number // 1 cal = 4.1840 J
export type Second = number
export type SecondsSince1970 = number
export type Minute = number
export type DateString = string // 2018-11-20
export type DateTimeString = string // 2018-11-20T00:00:00
export type YearWeekString = string // 201838

export type FunctionWithVoid = () => void
export type FunctionWithString = (string) => void
export type FunctionWithBoolean = (boolean) => void
export type FunctionWithNumber = (number) => void
export type JSONObject = any
export type JSONString = string
export type StringToString = any

export type Degree = number
export type Radian = number

export type Animal = 'cat' | 'dog' | 'cow'

How to choose Firebase vs Google Analytics

Issue #387

Google Analytics is shutting down. From Firebase Analytics console, we can choose to upgrade to Google Analytics, no code change is needed.

https://support.google.com/firebase/answer/9167112?hl=en

In October 2019, we will start to sunset Google Analytics mobile-apps reporting based on the Google Analytics Services SDKs for Android and iOS.

https://firebase.googleblog.com/2019/07/firebase-google-analytics-upgrade.html

Thanks to our continued partnership with Google Analytics, you can now upgrade your Firebase projects to the next generation of app analytics!

https://www.e-nor.com/blog/google-analytics/google-analytics-unifies-app-and-website-measurement

Google Analytics team has officially launched a new type of GA properties called “App + Web” to open public beta

It is a new GA property type that allows you to combine app and web data for unified reporting and analysis

Over the coming weeks, those who have existing Firebase projects will be able to upgrade your projects to the next generation Google Analytics experience as follows:

How to check app running on jailbreak iOS device

Issue #385

From https://github.com/OneSignal/OneSignal-iOS-SDK/blob/master/iOS_SDK/OneSignalSDK/Source/OneSignalJailbreakDetection.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
+ (BOOL)isJailbroken {

#if !(TARGET_IPHONE_SIMULATOR)

FILE *file = fopen("/Applications/Cydia.app", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/Library/MobileSubstrate/MobileSubstrate.dylib", "r");
if (file) {
fclose(file);
return YES;
}

file = fopen("/bin/bash", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/usr/sbin/sshd", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/etc/apt", "r");
if (file) {
fclose(file);
return YES;
}
file = fopen("/usr/bin/ssh", "r");
if (file) {
fclose(file);
return YES;
}

NSFileManager *fileManager = [NSFileManager defaultManager];

if ([fileManager fileExistsAtPath:@"/Applications/Cydia.app"])
return YES;
else if ([fileManager fileExistsAtPath:@"/Library/MobileSubstrate/MobileSubstrate.dylib"])
return YES;
else if ([fileManager fileExistsAtPath:@"/bin/bash"])
return YES;
else if ([fileManager fileExistsAtPath:@"/usr/sbin/sshd"])
return YES;
else if ([fileManager fileExistsAtPath:@"/etc/apt"])
return YES;
else if ([fileManager fileExistsAtPath:@"/usr/bin/ssh"])
return YES;

// Omit logic below since they show warnings in the device log on iOS 9 devices.
if (NSFoundationVersionNumber > 1144.17) // NSFoundationVersionNumber_iOS_8_4
return NO;

// Check if the app can access outside of its sandbox
NSError *error = nil;
NSString *string = @".";
[string writeToFile:@"/private/jailbreak.txt" atomically:YES encoding:NSUTF8StringEncoding error:&error];
if (!error)
return YES;
else
[fileManager removeItemAtPath:@"/private/jailbreak.txt" error:nil];

// Check if the app can open a Cydia's URL scheme
if ([[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:@"cydia://package/com.example.package"]])
return YES;

#endif

return NO;
}

How to create bounce animation programmatically in Android

Issue #383

Right click res -> New -> Android Resource Directory, select anim and name it anim
Right click res/anim -> New -> Android Resource file, name it bounce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate
android:fromYDelta="0"
android:toYDelta="-100"
android:repeatCount="infinite" />
</set>
```

We have to set `repeatCount` in xml, setting in code does not work !!

```kt
val bounce = AnimationUtils.loadAnimation(context, R.anim.bounce)
bounce.repeatMode = Animation.REVERSE
bounce.duration = (1000..2000).random().toLong()

imageView.startAnimation(bounce)

How to use point in dp programmatically in Android

Issue #382

1
2
3
4
5
6
7
8
9
10
import android.content.Context

fun Int.toDp(context: Context?): Int {
if (context != null) {
val scale = context.resources.displayMetrics.density
return (this.toFloat() * scale + 0.5f).toInt()
} else {
return 0
}
}
1
2
val set = ConstraintSet()
set.setMargin(imageView.id, ConstraintSet.RIGHT, rightMargin.toDp(150))

Read more

How to create constraints programmatically with ConstraintLayout in Android

Issue #381

From API < 17, there is ViewCompat.generateViewId()
For API 17, there is View.generateViewId()

Note that to use ConstraintSet, all views under ConstraintLayout inside xml must have unique id

1
2
3
4
5
6
7
8
9
val imageView = ImageView(context)
imageView.id = View.generateViewId()
imageView.setImageResource(resId)
constraintLayout.addView(imageView)

val set = ConstraintSet()
set.clone(constraintLayout)
set.connect(imageView.id, ConstraintSet.RIGHT, ConstraintSet.PARENT_ID, ConstraintSet.RIGHT)
set.applyTo(constraintLayout)

How to use custom font as resource in Android

Issue #380

Downloadable fonts

https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts

Android 8.0 (API level 26) and Android Support Library 26 introduce support for APIs to request fonts from a provider application instead of bundling files into the APK or letting the APK download fonts. The feature is available on devices running Android API versions 14 and higher through the Support Library 26

Before

  • Select File -> New -> Folder -> Assets Folder to create src/main/assets/fonts
1
2
al myTypeface = Typeface.createFromAsset(assets, "fonts/myFont.ttf")
myTextView.typeface = myTypeface

In res

Create font directory

Right click res -> New -> Android Resource Directory, select font and name the folder font

Add custom fonts to res/font folder. Note that name must be lower case and underscore, like opensans_extrabolditalic.ttf

Right click res/font -> New -> Font resource file to create font family

opensans.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?xml version="1.0" encoding="utf-8"?>
<font-family
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" >
<font
android:font="@font/opensans_regular"
android:fontStyle="normal"
android:fontWeight="400"
app:fontFamily="@font/opensans_regular"
app:fontStyle="normal"
app:fontWeight="400" />
<font
android:font="@font/opensans_semibold"
android:fontStyle="normal"
android:fontWeight="400"
app:fontFamily="@font/opensans_semibold"
app:fontStyle="normal"
app:fontWeight="400" />
<font
android:font="@font/opensans_bold"
android:fontStyle="normal"
android:fontWeight="400"
app:fontFamily="@font/opensans_bold"
app:fontStyle="normal"
app:fontWeight="400" />
</font-family>
```

Then use

```xml
<TextView
android:fontFamily="@font/opensans_bold"
android:textSize="26dp"
/>

Read more

How to get Hacker News top stories using parallel coroutine and Retrofit

Issue #379

1
2
3
4
5
6
7
interface Api {
@GET("topstories.json?print=pretty")
suspend fun getTopStories(): List<Int>

@GET("item/{id}.json?print=pretty")
suspend fun getStory(@Path("id") id: Int): Item
}
1
2
3
4
5
6
7
8
9
class Repo {
fun api(): Api {
return Retrofit.Builder()
.baseUrl("https://hacker-news.firebaseio.com/v0/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(Api::class.java)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ViewModel(val repo: Repo): ViewModel() {
val items = MutableLiveData<ArrayList<Item>>()

suspend fun load() {
try {
val ids = repo.api()
.getTopStories()
.take(20)

val items = ids.map {
repo.api().getStory(it)
}
this.items.value = items.toCollection(ArrayList())
} catch (e: Exception) {
this.items.value = arrayListOf()
}
}
}

Running parallel

The above run in serial. To run in parallel, we can use async

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

class ViewModel(val repo: Repo): ViewModel() {
val items = MutableLiveData<ArrayList<Item>>()

suspend fun load() {
try {
val ids = repo.api()
.getTopStories()
.take(20)

coroutineScope {
val items = ids
.map { async { repo.api().getStory(it) } }
.awaitAll()

this@ViewModel.items.value = items.toCollection(ArrayList())
}

} catch (e: Exception) {
this.items.value = arrayListOf()
}
}
}

Parallel decomposition

https://medium.com/@elizarov/structured-concurrency-722d765aa952

With structured concurrency async coroutine builder became an extension on CoroutineScope just like launch did. You cannot simply write async { … } anymore, you have to provide a scope. A proper example of parallel decomposition becomes:

coroutineScope

https://proandroiddev.com/part-2-coroutine-cancellation-and-structured-concurrency-2dbc6583c07d

coroutineScope function can be used to create a custom scope that suspends and only completes when all coroutines launched within that scope complete. If any of the children coroutines within the coroutineScope throws an exception, all other running sibling coroutines gets cancelled and this exception is propagated up the hierarchy. If the parent coroutine at the top of the hierarchy does not handle this error, it will also be cancelled.

awaitAll

https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/await-all.html

Awaits for completion of given deferred values without blocking a thread and resumes normally with the list of values when all deferred computations are complete or resumes with the first thrown exception if any of computations complete exceptionally including cancellation.

This function is not equivalent to deferreds.map { it.await() } which fails only when it sequentially gets to wait for the failing deferred, while this awaitAll fails immediately as soon as any of the deferreds fail.

This suspending function is cancellable. If the Job of the current coroutine is cancelled or completed while this suspending function is waiting, this function immediately resumes with CancellationException.

Read more

How to show generic list in Fragment in Android

Issue #378

After having a generic RecyclerView, if we want to show multiple kinds of data in Fragment, we can use generic.

We may be tempted to use interface or protocol, but should prefer generic.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FeedFragment() : Fragment() {
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)

val mainViewModel: MainViewModel = ViewModelProviders.of(activity!!).get(MainViewModel::class.java)
mainViewModel.resId.observe(viewLifecycleOwner, Observer {
when (it) {
R.id.gitHub -> { handleGitHub() }
R.id.hackerNews -> { handleHackerNews() }
R.id.reddit -> { handleReddit() }
R.id.dev -> { handleDev() }
R.id.productHunt -> { handleProductHunt() }
else -> {}
}
})

recyclerView.layoutManager = LinearLayoutManager(context)
}
}

The difference between each kind are

  • The type of model
  • The type of Adapter
  • How to observe from viewModel
  • How to load from viewModel

Here we also use lifecycleScope from lifecycle runtime ktx

1
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0-alpha01"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private fun <T> handle(
makeResId: () -> Int,
makeAdapter: () -> com.myapp.Adapter<T>,
observe: ((ArrayList<T>) -> Unit) -> Unit,
load: suspend () -> Unit
) {
(activity as AppCompatActivity).toolbar.title = getString(makeResId())
val adapter = makeAdapter()
recyclerView.adapter = adapter
observe {
adapter.update(it)
}
fun doLoad() {
viewLifecycleOwner.lifecycleScope.launch {
progressBar.visibility = View.VISIBLE
load()
progressBar.visibility = View.GONE
swipeRefreshLayout.isRefreshing = false
}
}
doLoad()
swipeRefreshLayout.setOnRefreshListener {
doLoad()
}
}

Then we just need to provide the required data

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private fun handleDev() {
val viewModel: com.myapp.ViewModel by viewModel()
handle(
{ R.string.menu_dev },
{ com.myapp.Adapter(items = arrayListOf()) },
{ completion ->
viewModel.items.observe(viewLifecycleOwner, Observer {
completion(it)
})
},
{
viewModel.load()
}
)
}

Read more

How to manage OneSignal push notification in iOS

Issue #377

OneSignal is an alternative for Parse for push notifications but the sdk has many extra stuff and assumptions and lots of swizzling.

We can just use Rest to make API calls. From https://github.com/onmyway133/Dust

Every official push notification SDK can do many things

  • Register device token. This is crucial for the notification to get from your backend -> APNS -> device
  • Manage player id, user id, arn, …This is used to associate with device token
  • Manager tag, topic, subscription, segments, …This is used to group a set of device tokens
  • Do swizzling, update your application badge number, change your user notification settings, … without your knowing about that
  • Some other fancy stuffs
  • Dust does only one thing, which is push notification handling. The rest is under your control

OneSignal

1
2
3
4
5
6
7
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject : AnyObject]?) -> Bool {
OneSignal.appID = ""
}

func application(application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: NSData) {
OneSignal.handleDeviceToken(deviceToken)
}

Here is the implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import UIKit

struct Utils {

static func parse(deviceToken data: NSData) -> String {
let buffer = UnsafePointer<CChar>(data.bytes)
var string = ""

for i in 0..<data.length {
string += String(format: "%02.2hhx", arguments: [buffer[i]])
}

return string
}

static func deviceModel() -> String {
var systemInfo = utsname()
uname(&systemInfo)
var v = systemInfo.machine

var deviceModel = ""
let _ = withUnsafePointer(&v) {
deviceModel = String(UTF8String: UnsafePointer($0)) ?? ""
}

return deviceModel
}

static func systemVersion() -> String {
let version = NSProcessInfo.processInfo().operatingSystemVersion

return "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
}

static func language() -> String {
return NSLocale.preferredLanguages().first!
}

static func timezone() -> Int {
return NSTimeZone.localTimeZone().secondsFromGMT
}

static func soundFiles() -> [String] {
guard let resourcePath = NSBundle.mainBundle().resourcePath
else { return [] }

let files = try? NSFileManager.defaultManager()
.contentsOfDirectoryAtPath(resourcePath)
.filter {
return $0.hasSuffix(".wav") || $0.hasSuffix(".mp3")
}

return files ?? []
}

static func versionNumber() -> String? {
return NSBundle.mainBundle().infoDictionary?["CFBundleShortVersionString"] as? String
}

static func buildNumber() -> String? {
return NSBundle.mainBundle().infoDictionary?["CFBundleVersionString"] as? String
}

static func netType() -> Int {
// Reachability
return 0
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import Foundation

public struct UserDefaults {

struct Key {
static let playerID: String = "Dust-OneSignal-Player-ID-Key"
static let deviceToken: String = "Dust-OneSignal-Device-Token-Key"
static let subscribed: String = "Dust-OneSignal-Disable-Subscribed-Key"
}

public static var playerID: String? {
get {
return NSUserDefaults.standardUserDefaults().stringForKey(Key.playerID)
}

set {
NSUserDefaults.standardUserDefaults().setObject(newValue, forKey: Key.playerID)
NSUserDefaults.standardUserDefaults().synchronize()
}
}

public static var deviceToken: String? {
get {
return NSUserDefaults.standardUserDefaults().stringForKey(Key.deviceToken)
}

set {
NSUserDefaults.standardUserDefaults().setObject(newValue, forKey: Key.deviceToken)
NSUserDefaults.standardUserDefaults().synchronize()
}
}

public static var subscribed: Bool {
get {
return NSUserDefaults.standardUserDefaults().boolForKey(Key.subscribed)
}

set {
NSUserDefaults.standardUserDefaults().setBool(newValue, forKey: Key.subscribed)
NSUserDefaults.standardUserDefaults().synchronize()
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import Foundation
import Alamofire

public struct OneSignal {

static var appID: String = ""
static let version = "020115"
static let baseURL = NSURL(string: "https://onesignal.com/api/v1")!

enum NotificationType: Int {
case subscribed = 7
case unsubscribed = -2

static func value() -> Int {
return UserDefaults.subscribed
? NotificationType.subscribed.rawValue : NotificationType.unsubscribed.rawValue
}
}

enum Provisioning: Int {
case development = 1
}

public static func setup(appID appID: String) {
NSUserDefaults.standardUserDefaults().registerDefaults([
UserDefaults.Key.subscribed: true
])

OneSignal.appID = appID
}

public static func registerOrUpdateSession(completion: ((String?) -> Void)? = nil) {
guard let bundleID = NSBundle.mainBundle().bundleIdentifier,
let deviceToken = UserDefaults.deviceToken
else {
return
}

var params: [String: AnyObject] = [
"app_id" : appID,
"device_model" : Utils.deviceModel(),
"device_os" : Utils.systemVersion(),
"language" : Utils.language(),
"timezone" : NSNumber(integer: Utils.timezone()),
"device_type" : NSNumber(integer : 0),
"sounds" : Utils.soundFiles(),
"sdk" : version,
"identifier" : deviceToken,
"net_type" : NSNumber(integer: Utils.netType()),
"rooted": NSNumber(bool: false),
"as_id": "OptedOut",
"sdk_type": "native",
"ios_bundle": bundleID,
"game_version": Utils.versionNumber() ?? "",
"notification_types": NotificationType.value(),
]

#if DEBUG
params["test_type"] = Provisioning.development.rawValue
#endif

let url: NSURL

if let playerID = UserDefaults.playerID {
url = baseURL.URLByAppendingPathComponent("players/\(playerID)/on_session")
} else {
url = baseURL.URLByAppendingPathComponent("players")
}

Alamofire
.request(.POST, url, parameters: params)
.responseJSON { response in
guard let json = response.result.value as? [String: AnyObject]
else {
completion?(nil)
return
}

if let id = json["id"] as? String {
UserDefaults.playerID = id
completion?(id)
} else if let value = json["success"] as? Int,
playerID = UserDefaults.playerID where value == 1 {
completion?(playerID)
} else {
completion?(nil)
}
}
}

public static func handle(deviceToken data: NSData) {
UserDefaults.deviceToken = Utils.parse(deviceToken: data)
registerOrUpdateSession()
}

public static func update(subscription subscribed: Bool) {
guard let playerID = UserDefaults.playerID else { return }
UserDefaults.subscribed = subscribed

let url = baseURL.URLByAppendingPathComponent("players/\(playerID)")
let params: [String: AnyObject] = [
"app_id": appID,
"notification_types": NotificationType.value()
]

Alamofire
.request(.PUT, url, parameters: params)
.responseJSON { response in
print(response)
}
}

public static func update(badge count: Int) {
guard let playerID = UserDefaults.playerID else { return }

let url = baseURL.URLByAppendingPathComponent("players/\(playerID)")
let params: [String: AnyObject] = [
"app_id": appID,
"badge_count": count
]

Alamofire
.request(.PUT, url, parameters: params)
.responseJSON { response in

}
}

public static func getPlayerID(completion: String -> Void) {
if let playerID = UserDefaults.playerID {
completion(playerID)
return
}

registerOrUpdateSession { playerID in
if let playerID = playerID {
completion(playerID)
}
}
}
}

How to do throttle and debounce using DispatchWorkItem in Swift

Issue #376

https://github.com/onmyway133/Omnia/blob/master/Sources/Shared/Debouncer.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Foundation

public class Debouncer {
private let delay: TimeInterval
private var workItem: DispatchWorkItem?

public init(delay: TimeInterval) {
self.delay = delay
}

/// Trigger the action after some delay
public func run(action: @escaping () -> Void) {
workItem?.cancel()
workItem = DispatchWorkItem(block: action)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem!)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import XCTest

class DebouncerTests: XCTestCase {

func testDebounce() {
let expectation = self.expectation(description: #function)
let debouncer = Debouncer(delay: 0.5)
var value = 0

debouncer.run(action: {
value = 1
})

debouncer.run(action: {
value = 2
})

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2, execute: {
debouncer.run(action: {
value = 3
})
})

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.71, execute: {
XCTAssertEqual(value, 3)
expectation.fulfill()
})

wait(for: [expectation], timeout: 1.2)
}
}

How to simplify UIApplication life cycle observation in iOS

Issue #375

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
final class LifecyclerHandler {
private var observer: AnyObject!
var action: (() -> Void)?
private let debouncer = Debouncer(delay: 1.0)

func setup() {
observer = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main,
using: { [weak self] _ in
self?.debouncer.run {
self?.action?()
}
})
}
}
1
2
3
4
5
6
7
8
9
10
11
12
private let lifecycleHandler = LifecyclerHandler()

override func viewDidLoad() {
super.viewDidLoad()


lifecycleHandler.action = {
Deps.userHandler.refreshToken()
}

lifecycleHandler.setup()
}

How to do UITests with Google Maps on iOS

Issue #374

Interact with GMSMapView

Add accessibilityIdentifier to the parent view of GMSMapView. Setting directly onto GMSMapView has no effect

1
accessibilityIdentifier = "MapView"
1
2
3
4
5
6
7
let map = app.otherElements.matching(identifier: "MapView").element(boundBy: 0)
map.pinch(withScale: 2, velocity: 1)
map.rotate(CGFloat.pi/3, withVelocity: 1.0)
map.swipeLeft()
map.swipeRight()
map.swipeDown()
map.swipeDown()

Interact with GMSMarker (1st try)

Need to enable accessibility

1
mapView.accessibilityElementsHidden = false

Can’t use pinch to zoom out with UITests, so need to mock location !!!

1
map().pinch(withScale: 0.05, velocity: -1)

Need to use gpx to mock to preferred location

1
2
3
4
let map = app.otherElements[Constant.AccessibilityId.mapView.rawValue]
let pin = app.otherElements
.matching(identifier: Constant.AccessibilityId.pin.rawValue)
.element(boundBy: 0)

Try isAccessibilityElement = true for PinView, can’t touch!!
Use coordinate, can’t touch !!

1
2
let coordinate = pin.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()

Try traversing all the pins, can’t touch

1
2
3
4
5
6
Array(0..<pins.count).forEach {
let pin = pins.element(boundBy: $0)
if pin.isHittable {
pin.tap()
}
}

When po app.otherElements, coordinates are outside screen

1
Other, {{1624.0, 1624.0}, {30.0, 30.0}}, identifier: 'pin', label: 'Hello world'

Interact with GMSMarker (works)

My PinView has isHittable being false, no matter how I use coordinate or enable it. It can’t be touched.

Go to Xcode -> Open Developer Tool -> Accessibility Inspector to inspect our app in iOS simulator

inspector

It turns out that if I do

1
po app.buttons

It shows all the GMSMarker, but with identifier having class name MyApp.MyStopMarker, so just need to use buttons

1
2
3
4
5
6
7
8
9
10
11
extension NSPredicate {
static func contains(identifier: String) -> NSPredicate {
return NSPredicate(format: "identifier CONTAINS[c] '\(text)'")
}
}

let pin = map()
.buttons.matching(NSPredicate.contains("MyStopMarker"))
.element(boundBy: 0)

pin.tap()

Updated at 2021-01-26 09:47:41

Make to make rounded background UIButton in iOS

Issue #373

UIButton.contentEdgeInsets does not play well with Auto Layout, we need to use intrinsicContentSize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final class InsetButton: UIButton {
required init(text: String) {
super.init(frame: .zero)

titleLabel?.textColor = .white
setTitle(text, for: .normal)

layer.cornerRadius = 15
layer.masksToBounds = true
backgroundColor = .black
isUserInteractionEnabled = false
}

required init?(coder aDecoder: NSCoder) {
fatalError()
}

override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(width: size.width + 24, height: size.height)
}
}

How to make scrolling UIScrollView with Auto Layout in iOS

Issue #371

Scrolling UIScrollView is used in common scenarios like steps, onboarding.
From iOS 11, UIScrollView has contentLayoutGuide and frameLayoutGuide

Docs

https://developer.apple.com/documentation/uikit/uiscrollview/2865870-contentlayoutguide

Use this layout guide when you want to create Auto Layout constraints related to the content area of a scroll view.

https://developer.apple.com/documentation/uikit/uiscrollview/2865772-framelayoutguide

Use this layout guide when you want to create Auto Layout constraints that explicitly involve the frame rectangle of the scroll view itself, as opposed to its content rectangle.

Code

I found out that using contentLayoutGuide and frameLayoutGuide does not work in iOS 11, when swiping to the next page, it breaks the constraints. iOS 12 works well, so we have to check iOS version

Let the contentView drives the contentSize of scrollView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import UIKit

final class PagerView: UIView {
let scrollView = UIScrollView()

private(set) var pages: [UIView] = []
private let contentView = UIView()

override init(frame: CGRect) {
super.init(frame: frame)

setup()
}

required init?(coder aDecoder: NSCoder) {
fatalError()
}

private func setup() {
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false

addSubview(scrollView)
scrollView.addSubview(contentView)

if #available(iOS 12.0, *) {
scrollView.translatesAutoresizingMaskIntoConstraints = false
contentView.translatesAutoresizingMaskIntoConstraints = false

NSLayoutConstraint.on([
scrollView.frameLayoutGuide.pinEdges(view: self)
])

NSLayoutConstraint.on([
scrollView.contentLayoutGuide.pinEdges(view: contentView),
[scrollView.contentLayoutGuide.heightAnchor.constraint(
equalTo: scrollView.frameLayoutGuide.heightAnchor
)]
])
} else {
NSLayoutConstraint.on([
scrollView.pinEdges(view: self),
scrollView.pinEdges(view: contentView)
])

NSLayoutConstraint.on([
contentView.heightAnchor.constraint(equalTo: heightAnchor)
])
}
}

func update(pages: [UIView]) {
clearExistingViews()

self.pages = pages
setupConstraints()
}

private func setupConstraints() {
pages.enumerated().forEach { tuple in
let index = tuple.offset
let page = tuple.element

contentView.addSubview(page)

NSLayoutConstraint.on([
page.topAnchor.constraint(equalTo: scrollView.topAnchor),
page.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor),
page.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
])

if index == 0 {
NSLayoutConstraint.on([
page.leftAnchor.constraint(equalTo: contentView.leftAnchor)
])
} else {
NSLayoutConstraint.on([
page.leftAnchor.constraint(equalTo: pages[index - 1].rightAnchor)
])
}

if index == pages.count - 1 {
NSLayoutConstraint.on([
page.rightAnchor.constraint(equalTo: contentView.rightAnchor)
])
}
}
}

private func clearExistingViews() {
pages.forEach {
$0.removeFromSuperview()
}
}
}
1
2
3
4
5
6
7
8
9
10
extension UILayoutGuide {
func pinEdges(view: UIView, inset: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: inset.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: inset.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: inset.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: inset.bottom)
]
}
}

How to use Product Hunt GraphQL API with Retrofit

Issue #370

Define response model

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import com.squareup.moshi.Json

data class Response(
@field:Json(name="data") val data: ResponseData
)

data class ResponseData(
@field:Json(name="posts") val posts: Posts
)

data class Posts(
@field:Json(name="edges") val edges: List<Edge>
)

data class Edge(
@field:Json(name="node") val node: Item
)

data class Item(
@field:Json(name="id") val id: String,
@field:Json(name="name") val name: String,
@field:Json(name="url") val url: String,
@field:Json(name="tagline") val tagline: String,
@field:Json(name="featuredAt") val featuredAt: String,
@field:Json(name="votesCount") val votesCount: Int,
@field:Json(name="commentsCount") val commentsCount: Int,
@field:Json(name="thumbnail") val thumbnail: Thumbnail
)

data class Thumbnail(
@field:Json(name="url") val ur: String
)

Here is the query

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
posts {
edges {
node {
id
name
url
tagline
featuredAt
votesCount
commentsCount
thumbnail {
url
}
}
}
}
}

Here’s how request looks in Insomnia

1
2
3
4
5
6
7
8
9
10
> POST /v2/api/graphql HTTP/1.1
> Host: api.producthunt.com
> User-Agent: insomnia/6.6.2
> Cookie: __cfduid=d9a588136cbb286b156d8e4a873d52a301566795296
> Accept: application/json
> Content-Type: application/json
> Authorization: Bearer 068665d215cccad9123449841463b1248da07123418915a192a1233dedfd23b2
> Content-Length: 241

| {"query":"{\n posts {\n edges {\n node {\n id\n name\n url\n tagline\n featuredAt\n votesCount\n commentsCount\n thumbnail {\n url\n }\n }\n }\n }\n}"}

To post as json, need to use object for Moshi to convert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
data class GetTopBody(
@field:Json(name="query") val queryString: String
)

interface Api {
@Headers(
"Content-Type: application/json",
"Accept: application/json",
"Authorization: Bearer 068665d215cccad9123449841463b1248da07123418915a192a1233dedfd23b2",
"Host: api.producthunt.com",
"User-Agent: insomnia/6.6.2"
)

@POST("./")
suspend fun getTop(
@Body body: GetTopBody
): Response
}

And consume it in ViewModel. Use multiline string interpolation. No need to set contentLength

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class ViewModel(val repo: Repo): ViewModel() {
val items = liveData {
val queryString = """
{
posts {
edges {
node {
id
name
url
tagline
featuredAt
votesCount
commentsCount
thumbnail {
url
}
}
}
}
}
""".trimIndent()

val body = GetTopBody(queryString)

try {
val response = repo.api().getTop(body)
val items = response.data.posts.edges.map { it.node }
emit(items.toCollection(ArrayList()))
} catch (e: Exception) {
emit(arrayListOf<Item>())
}

}
}

The response looks like

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"data": {
"posts": {
"edges": [
{
"node": {
"id": "158359",
"name": "Toast",
"url": "https://www.producthunt.com/posts/toast-2?utm_campaign=producthunt-api&utm_medium=api-v2&utm_source=Application%3A+PH+API+Explorer+%28ID%3A+9162%29",
"tagline": "Organise tabs into organised sessions",
"featuredAt": "2019-08-25T07:00:00Z",
"votesCount": 318,
"commentsCount": 16,
"thumbnail": {
"url": "https://ph-files.imgix.net/a169654a-850d-4b1c-80ba-be289f973fb7?auto=format&fit=crop"
}
}
},
{
"node": {
"id": "165621",
"name": "Tree",
"url": "https://www.producthunt.com/posts/tree-2?utm_campaign=producthunt-api&utm_medium=api-v2&utm_source=Application%3A+PH+API+Explorer+%28ID%3A+9162%29",
"tagline": "Write documents in tree-like organisation with Markdown",
"featuredAt": "2019-08-25T09:10:53Z",
"votesCount": 227,
"commentsCount": 11,
"thumbnail": {
"url": "https://ph-files.imgix.net/68b1f007-e630-4c79-8a27-756ec364343f?auto=format&fit=crop"
}
}
}
]
}
}
}

Map

Instead of using an object, we can use Map. If using HashMap, I get

Unable to create @Body converter for java.util.HashMap<java.lang.String, java.lang.String>

1
2
3
4
5
6
@POST("./")
suspend fun getTop(
@Body body: Map<String, String>
): Response

val body = mapOf("query" to queryString)

Troubleshooting

Use Network Profiler to inspect failure View > Tool Windows > Profiler

query

Read more

How to fix Auto Layout issues in iOS

Issue #369

UITemporaryLayoutHeight and UITemporaryLayoutWidth

  • Demystify warnings with https://www.wtfautolayout.com/
  • Reduce priority
  • Use Auto Layout directly instead of using manual frame layout, specially for scrolling pager

NSAutoresizingMaskLayoutConstraint

  • Check that a view ACTUALLY has translatesAutoresizingMaskIntoConstraints set to false

UISV-spacing, UISV-distributing

  • Check UIStackView
  • Set stackview.alignment = .center if you see UIStackView trying to set same trailing or leading edges for its subviews
  • Reduce priority if there’s edge constraints break from subviews to UIStackView

Intrinsic size between UIImageView and UILabel

  • When constraint to each other, can cause UILabel to disappear
  • Reduce compression resistance
1
imageView.setContentCompressionResistancePriority(UILayoutPriority.defaultLow, for: .vertical)

UIAlertController sheet width == - 16

https://stackoverflow.com/a/58666480/1418457

Read more

How to simplify anchor with NSLayoutConstraint in iOS

Issue #368

See https://github.com/onmyway133/Omnia/blob/master/Sources/iOS/NSLayoutConstraint.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
extension NSLayoutConstraint {

/// Disable auto resizing mask and activate constraints
///
/// - Parameter constraints: constraints to activate
static func on(_ constraints: [NSLayoutConstraint]) {
constraints.forEach {
($0.firstItem as? UIView)?.translatesAutoresizingMaskIntoConstraints = false
$0.isActive = true
}
}

static func on(_ constraintsArray: [[NSLayoutConstraint]]) {
let constraints = constraintsArray.flatMap({ $0 })
NSLayoutConstraint.on(constraints)
}

func priority(_ value: Float) -> NSLayoutConstraint {
priority = UILayoutPriority(value)
return self
}
}

extension Array where Element == NSLayoutConstraint {
func priority(_ value: Float) -> [NSLayoutConstraint] {
forEach {
$0.priority = UILayoutPriority(value)
}

return self
}
}
1
2
3
4
5
6
7
8
9
10
extension UILayoutGuide {
func pinEdges(view: UIView, inset: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: inset.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: inset.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: inset.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: inset.bottom)
]
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
extension UIView {
func pinEdges(view: UIView, inset: UIEdgeInsets = UIEdgeInsets.zero) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: inset.left),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: inset.right),
topAnchor.constraint(equalTo: view.topAnchor, constant: inset.top),
bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: inset.bottom)
]
}

func pinCenter(view: UIView, offset: CGPoint = .zero) -> [NSLayoutConstraint] {
return [
centerXAnchor.constraint(equalTo: view.centerXAnchor, constant: offset.x),
centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: offset.y)
]
}

func padding(view: UIView, _ constant: CGFloat = 0) -> [NSLayoutConstraint] {
return [
leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: constant),
trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -constant)
]
}

func size(_ constant: CGFloat) -> [NSLayoutConstraint] {
return [
widthAnchor.constraint(equalToConstant: constant),
heightAnchor.constraint(equalToConstant: constant)
]
}

func size(_ width: CGFloat, _ height: CGFloat) -> [NSLayoutConstraint] {
return [
widthAnchor.constraint(equalToConstant: width),
heightAnchor.constraint(equalToConstant: height)
]
}

func addSubviews(_ views: [UIView]) {
views.forEach {
addSubview($0)
}
}
}

How to get trending repos on GitHub using Retrofit

Issue #367

1
https://api.github.com/search/repositories?sort=stars&order=desc&q=language:javascript,java,swift,kotlin&q=created:>2019-08-21
1
2
3
4
5
6
7
8
interface Api {
@GET("https://api.github.com/search/repositories")
suspend fun getTrendingRepos(
@Query("sort") sort: String,
@Query("order") order: String,
@Query("q") qs: List<String>
): Response
}
1
2
3
4
5
6
7
8
9
10

class Repo {
fun api(): Api {
return Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(Api::class.java)
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class ViewModel(val repo: Repo, val dateProvider: DateProvider): ViewModel() {
val items = MutableLiveData<ArrayList<Item>>()

suspend fun load() {
try {
val order = "desc"
val sort = "star"

val formatter = SimpleDateFormat("YYYY-MM-dd")
val qs = listOf(
"language:javascript,java,swift,kotlin",
"q=created:>${formatter.format(dateProvider.yesterday)}"
)

val response = repo.api().getTrendingRepos(sort=sort, order=order, qs=qs)
this.items.value = response.items.toCollection(ArrayList())
} catch (e: Exception) {
this.items.value = arrayListOf()
}
}
}

How to use Retrofit in Android

Issue #366

Code uses Retrofit 2.6.0 which has Coroutine support

app/build.gradle

1
2
3
4
5
6
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.2.0-alpha01"

implementation "com.squareup.moshi:moshi:$Version.moshi"

implementation "com.squareup.retrofit2:retrofit:$Version.retrofit"
implementation "com.squareup.retrofit2:converter-moshi:$Version.retrofit"

Api.kt

1
2
3
4
5
6
import retrofit2.http.GET

interface Api {
@GET("api/articles")
suspend fun getArticles(): List<Article>
}

Repo.kt

1
2
3
4
5
6
7
8
9
10
11
12
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory

class Repo {
fun get(): Api {
return Retrofit.Builder()
.baseUrl("https://dev.to")
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(Api::class.java)
}
}

ViewModel.kt

1
2
3
4
5
6
7
8
9
import androidx.lifecycle.ViewModel
import androidx.lifecycle.liveData
import kotlinx.coroutines.Dispatchers

class ViewModel(val repo: Repo): ViewModel() {
val articles = liveData(Dispatchers.Main) {
emit(repo.get().getArticles().toCollection(ArrayList()))
}
}

Article.kt

1
2
3
4
5
6
import com.squareup.moshi.Json

data class Article(
@field:Json(name="type_of") val typeOf: String,
@field:Json(name="title") val title: String
)

How to handle link clicked in WKWebView in iOS

Issue #365

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import WebKit
import SafariServices

final class WebViewHandler: NSObject, WKNavigationDelegate {
var show: ((UIViewController) -> Void)?
let supportedSchemes = ["http", "https"]

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
defer {
decisionHandler(.allow)
}

guard
navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url,
let scheme = url.scheme,
supportedSchemes.contains(scheme)
else {
return
}

let controller = SFSafariViewController(url: url)
show?(controller)
}
}

How to use AppFlowController in iOS

Issue #364

AppFlowController.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import UIKit
import GoogleMaps
import Stripe

final class AppFlowController: UIViewController {
private lazy var window = UIWindow(frame: UIScreen.main.bounds)

func configure() {
GMSServices.provideAPIKey(Constant.googleMapsApiKey)
STPPaymentConfiguration.shared().publishableKey = Constant.stripeKey
}

func start() {
if Deps.onboardingHandler.hasOnboarded {
startMain()
} else {
startOnboarding()
}

window.makeKeyAndVisible()
}

func startOnboarding() {
let controller = OnboardingController()
controller.delegate = self
window.rootViewController = controller
}

func startMain() {
let controller = MainFlowController()
window.rootViewController = controller
controller.start()
}
}

extension AppFlowController: OnboardingControllerDelegate {
func onboardingControllerDidFinish(_ controller: OnboardingController) {
Deps.onboardingHandler.hasOnboarded = true
startMain()
}
}

AppDelegate.swift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

private let appFlowController = AppFlowController()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

appFlowController.configure()
appFlowController.start()

UIApplication.shared.registerForRemoteNotifications()
FirebaseApp.configure()
return true
}
}

How to use ViewModel and ViewModelsProviders in Android

Issue #363

ViewModels a simple example

https://medium.com/androiddevelopers/viewmodels-a-simple-example-ed5ac416317e

Rotating a device is one of a few configuration changes that an app can go through during its lifetime, including keyboard availability and changing the device’s language. All of these configuration changes cause the Activity to be torn down and recreated

The ViewModel class is designed to hold and manage UI-related data in a life-cycle conscious way. This allows data to survive configuration changes such as screen rotations.

The ViewModel exists from when the you first request a ViewModel (usually in the onCreate the Activity) until the Activity is finished and destroyed. onCreate may be called several times during the life of an Activity, such as when the app is rotated, but the ViewModel survives throughout.

Storing an Application context in a ViewModel is okay because an Application context is tied to the Application lifecycle. This is different from an Activity context, which is tied to the Activity lifecycle. In fact, if you need an Application context, you should extend AndroidViewModel which is simply a ViewModel that includes an Application reference.

The first time the ViewModelProviders.of method is called by MainActivity, it creates a new ViewModel instance. When this method is called again, which happens whenever onCreate is called, it will return the pre-existing ViewModel associated with the specific Court-Counter MainActivity

1
ViewModelProviders.of(<THIS ARGUMENT>).get(ScoreViewModel.class);

This allows you to have an app that opens a lot of different instances of the same Activity or Fragment, but with different ViewModel information

Dive deep into Android’s ViewModel — Android Architecture Components

https://android.jlelse.eu/dive-deep-into-androids-viewmodel-android-architecture-components-e0a7ded26f70

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* Utilities methods for {@link ViewModelStore} class.
*/
public class ViewModelProviders {
/**
* Creates a {@link ViewModelProvider}, which retains ViewModels while a scope of given Activity
* is alive. More detailed explanation is in {@link ViewModel}.
* <p>
* It uses {@link ViewModelProvider.AndroidViewModelFactory} to instantiate new ViewModels.
*
* @param activity an activity, in whose scope ViewModels should be retained
* @return a ViewModelProvider instance
*/
@NonNull
@MainThread
public static ViewModelProvider of(@NonNull FragmentActivity activity) {
ViewModelProvider.AndroidViewModelFactory factory =
ViewModelProvider.AndroidViewModelFactory.getInstance(
checkApplication(activity));
return new ViewModelProvider(ViewModelStores.of(activity), factory);
}

}

It seems like ViewModelProviders.of is just a factory of ViewModelProvider, which depends upon ViewModelFactory and a ViewModelStore.

1
2
MyViewModelFactory factory = new MyViewModelFactory(data1, data2);
ViewModelProviders.of(this, factory).get(MyViewModel.class);
1
2
3
public class ViewModelStore {
private final HashMap<String, ViewModel> mMap = new HashMap<>();
}

HolderFragment

HolderFragment is a regular Android Headless Fragment. It is the scope where all ViewModels inside the ViewModelStore will live.

1
2
3
4
5

public class HolderFragment extends Fragment implements ViewModelStoreOwner {
private static final HolderFragmentManager sHolderFragmentManager = new HolderFragmentManager();
private ViewModelStore mViewModelStore = new ViewModelStore();
}
1
2
3
4
static class HolderFragmentManager {
private Map<Activity, HolderFragment> mNotCommittedActivityHolders = new HashMap<>();
private Map<Fragment, HolderFragment> mNotCommittedFragmentHolders = new HashMap<>();
}

Who owns the HolderFragment?

The HolderFragment has an inner static class named HolderFragmentManager. The HolderFragmentManager creates and manages HolderFragment instances.
After creating the instances it associates them with an Activity or Fragment.

The whole process is done using the methods holderFragmentFor(Activity) and holderFragmentFor(Fragment).

How does HolderFragment retains the state ?

By setting retain instance to true and not providing a view, the HolderFragment becomes essentially a headless Fragment that is retained for as long as the Activity is not destroyed.

1
2
3
public HolderFragment() {
setRetainInstance(true);
}

void setRetainInstance (boolean retain)

Control whether a fragment instance is retained across Activity re-creation (such as from a configuration change). This can only be used with fragments not in the back stack. If set, the fragment lifecycle will be slightly different when an activity is recreated:

Retrieving ViewModel instance

1
get(MyViewModel.class)

It tries to retrieve a MyViewModel instance from the store. If none is there, it uses the factory to create it and then stores it into HashMap<String, ViewModel>. In order to retrieve the already created ViewModel, it generates a key from the class qualified name.

How to declare UIGestureRecognizer in iOS

Issue #362

1
2
3
4
5
let tapGR = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))

@objc private func handleTap(_ gr: UITapGestureRecognizer) {
didTouch?()
}

We need to use lazy instead of let for gesture to work

1
lazy var tapGR = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))