-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.html
1498 lines (1248 loc) · 85.4 KB
/
index.html
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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
<meta name="theme-color" content="#4F7DC9">
<meta charset="UTF-8">
<title>A Multiplatform Adventure</title>
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Source+Code+Pro:400|Roboto:400,300,400italic,500,700|Roboto+Mono">
<link rel="stylesheet" href="//fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://storage.googleapis.com/codelab-elements/codelab-elements.css">
<style>
.success {
color: #1e8e3e;
}
.error {
color: red;
}
</style>
</head>
<body>
<google-codelab-analytics gaid="UA-49880327-14"></google-codelab-analytics>
<google-codelab codelab-gaid=""
id="a-multiplatform-adventure"
title="A Multiplatform Adventure"
environment="web"
feedback-link="https://github.com/cmota/kmp-codelabs/issues">
<google-codelab-step label="Introduction" duration="90">
<p class="image-container"><img style="width: 501.50px" src="img/657b1858759b67ee.png"></p>
<p>Image downloaded from <a href="https://kotlinlang.org/lp/mobile/" target="_blank">https://kotlinlang.org/lp/mobile/</a>.</p>
<p><strong>Last Updated:</strong> 2020-10-08</p>
<h2 is-upgraded>Kotlin Multiplatform</h2>
<p>Before we start, it's important to mention that Kotlin Multiplatform is in <strong>alpha</strong>! During development you'll encounter several issues that will require your extra effort to solve and if you're relying on libraries you might need to wait for their updates before thinking to upgrade your kotlin version.</p>
<h2 class="checklist" is-upgraded><strong>What you'll learn</strong></h2>
<p>In this codelab, one of my main concerns when designing it was to use the latest kotlin version available, at this moment is version 1.4.10, and then get all the libraries working with it. With this, a couple of ones that I've used in the past were left behind, an example of this is <a href="https://github.com/touchlab-lab/FirestoreKMP" target="_blank">FirestoreKMP</a>, that is no longer supported. Nevertheless, there's a couple others that are being developed by the community and in a few months will be ready for Kotlin 1.4. For the time being and this codelab, I've adapted the exercises to show you how to migrate existing code written on both platforms to one that's going to be shared and with it we create a FirestoreKMM version 0.0.0.0.1. </p>
<h2 is-upgraded><strong>Important keywords</strong></h2>
<p>When we dive into Kotlin Multiplatform we start to see a couple of initials when referring for multiplatform development lets start by identifying them out:</p>
<ul>
<li>KMP, Kotlin Multiplatform </li>
</ul>
<p>It means you can use Kotlin to develop for all platforms out there: android, iOS, JS, server, etc.</p>
<ul>
<li>KMM, Kotlin Multiplatform Mobile</li>
</ul>
<p>It's the focus on using KMP on mobile development: android and iOS only.</p>
<h2 is-upgraded><strong>Why is it different?</strong></h2>
<p>I hear and read this question quite often, why is Kotlin Multiplatform different from the other cross-platform solutions? Why should I focus on moving my project to KMP instead of Flutter for instance?</p>
<p>First, let me start by saying that KMP is not a cross-platform solution, not the way that we've been used to at least. The goal is not to share your entire application code between Android and iOS, for instance, but instead, share your business logic and leave the UI to be developed natively.</p>
<p>There's a really good quote by Kevin Galligan that sums this up:</p>
<aside class="warning"><p>"Shared UI is a history of pain and failure.</p>
<p> Shared logic is the history of computers."</p>
<p>– Kevin Galligan</p>
</aside>
<p>We've all experienced this one way or another, either having to deal with cross-platform frameworks or by using apps developed with them.</p>
<p>A couple of years ago, iOS redesigned their entire UI. If your app was developed using natively when you'd make that upgrade all the components would automatically have the new look and feel. On the other hand, if you were using one of these frameworks you needed to wait until they updated their widgets to the new design and then compile and publish your app update. </p>
<p>In some cases this took months and on others I've uninstalled the app and looked for alternatives.</p>
<p>With this, it's also important to mention that when you select one of these platforms for your app you need to take into account how long the project will take. If you're going to develop your app during 1-2 years is one thing, if it's a long running project it's another. </p>
<p>Remember that you're committing to a specific framework that needs to:</p>
<ul>
<li>Have a strong community and documentation behind.</li>
<li>Provide fast resolution to issues and updates on the APIs/ components.</li>
<li>Developed by a trustworthy and long running company.</li>
</ul>
<p><br>All points are particularly important, especially when we're talking about long running projects, since you want in five to ten years to still be using that framework and not refactoring your intiring code to a new one just because the company stopped giving its tool support.</p>
<p>With this in Kotlin Multiplatform you can decide what you want to share, you can start by sharing your unit tests, then perhaps the database, your network module, etc. you decide what you want to share.</p>
<p>During the last Kotlin Conf (2019), Andrey Breslav on the opening keynote shared this slide:</p>
<p class="image-container"><img style="width: 624.00px" src="img/a908bf7f21a4d946.png"></p>
<p>A set of applications that are already in production using Kotlin Multiplatform. Each one with it's own approach on what they're sharing between the Android and iOS app.</p>
<p>Do you need more arguments to start? Easy, it's all Kotlin.</p>
<aside class="special"><p><strong>Note: </strong>Looking for a comparison between the most used market solutions for multiplatform development? Touchlab has a really good and detailed article here: <a href="https://touchlab.co/how-does-kotlin-multiplatform-stack-up-against-other-multiplatform-solutions-our-scorecard-explained/" target="_blank">How does Kotlin Multiplatform stack up against other solutions? Our scorecard, explained</a>.</p>
</aside>
</google-codelab-step>
<google-codelab-step label="Developing a KMM Application" duration="0">
<h2 is-upgraded><strong>All Together Now</strong></h2>
<p>I've developed two applications for this codelab: one for Android and the other for iOS. The entire business logic is shared between both platforms and only the UI itself is native.</p>
<p>Both applications have the same set of features:</p>
<ul>
<li>Allow to communicate with other people using the app.</li>
<li>Retrieve a list of conferences.</li>
<li>Filter this list according to a specific criteria.</li>
</ul>
<p>Along with this I've used the following libraries to put everything together:</p>
<ul>
<li><a href="https://github.com/firebase/" target="_blank">Firebase</a> (Firestore)</li>
</ul>
<p>There's no current version of a Firestore library that supports this with Kotlin 1.4, so we will implement this part ourselves.</p>
<ul>
<li><a href="https://github.com/ktorio/ktor" target="_blank">Ktor</a></li>
</ul>
<p>For network requests, in this case we will fetch the list of Android conferences from a Gist on Github.</p>
<ul>
<li><a href="https://github.com/Kotlin/kotlinx.serialization" target="_blank">kotlinx.serialization</a></li>
</ul>
<p>To parse the received data and convert it into a list of <code>Conference</code>s.</p>
<ul>
<li><a href="https://github.com/Kotlin/kotlinx.coroutines" target="_blank">kotlinx.coroutines</a></li>
</ul>
<p> Handle the different requests made in the app.</p>
<ul>
<li><a href="https://github.com/Kotlin/kotlinx-datetime" target="_blank">kotlinx-datetime</a></li>
</ul>
<p>Shared library to retrieve the current system timestamp.</p>
<ul>
<li><a href="https://github.com/cashapp/sqldelight" target="_blank">sqldelight</a></li>
</ul>
<p> Database used to store all the <code>Conference</code>s locally.</p>
<ul>
<li><a href="https://github.com/russhwolf/multiplatform-settings" target="_blank">multiplatform-settings</a></li>
</ul>
<p>Saves and loads local settings into <code>SharedPreferences</code> or on <code>NSUserDefaults</code> depending on the platform that's running the application.</p>
<p>All of these libraries are open source and with it you can track both the status of the project and check if there's any blocking issue/ solution to a problem that you might face.</p>
</google-codelab-step>
<google-codelab-step label="Set up the environment" duration="0">
<h2 is-upgraded>What do I need to start?</h2>
<p>There are a couple of options here depending on your own preference. Recently, after KMM plugin was released to Android Studio I've changed my setup to:</p>
<ul>
<li>Android Studio 4.0.1 with KMM plugin.</li>
<li>Xcode 11.7.</li>
</ul>
<p>You can also use AppCode instead of Xcode or Intellij IDEA instead of Android Studio.</p>
<p>There's a third option, that is to just use Android Studio with the KMM plugin for everything. Although you can compile for the iOS simulator with it if you're going to develop the UI you might face a couple of issues - sometimes there's no code highlight and you don't have auto complete nor UI preview. Which makes sense, since they weren't build for iOS development.</p>
<p>Briefly, my suggestion is to use Android Studio for Android and shared code and leave the iOS to Xcode - that's why it was built for.</p>
<h2 is-upgraded><strong>How to install the KMM plugin</strong></h2>
<p>Open Android Studio and follow the instructions:</p>
<ol type="1" start="1">
<li>On the top bar click on Android Studio → Preferences...</li>
<li>A new window opens and on the left side bar click on the entry saying "Plugins".</li>
<li>On the tab bar select "Marketplace".</li>
<li>Search for "Kotlin Multiplatform".</li>
<li>Click on "Install".</li>
</ol>
<p class="image-container"><img style="width: 624.00px" src="img/1d1e638519ee4b86.png"></p>
<p>After installing the IDE will restart and the plugin should now be enabled by default.</p>
<aside class="special"><p><strong>Note:</strong> Don't forget that this is still a preview so you will find a couple of bugs along the way. Fortunately, JetBrains team is actively working on them and you'll see a lot of suggestions on how to bypass them from the community.</p>
</aside>
<h2 is-upgraded><strong>How to create your first KMM project </strong></h2>
<p>Although you're going to use a project that already has all the UI, so you can just focus on sharing the code across both platforms. It's important to understand how to create a project from scratch.</p>
<p>For that, open the Android Studio and follow these instructions:</p>
<ol type="1" start="1">
<li>On the top bar go to "File" → "New" → "New project...".</li>
<li>"Select a Project Template" scroll to the bottom until you see "KMM Application".</li>
<li>Select that one.</li>
</ol>
<p class="image-container"><img style="width: 605.50px" src="img/a70e17f59ab57388.png"></p>
<ol type="1" start="4">
<li>Define the name, package name and it's location just like you normally would do on an Android project.</li>
</ol>
<p class="image-container"><img style="width: 624.00px" src="img/e5c1fe0f5a728275.png"></p>
<ol type="1" start="5">
<li>Click on "Finish" and wait a bit until the entire project is loaded and indexed.</li>
</ol>
<aside class="warning"><p><strong>Note: </strong>Don't press compile and run as soon as everything is ready. Depending on your jdk version you might need to upgrade the default gradle wrapper that's set on the template.</p>
</aside>
<p>Before compiling the app go to the <strong>gradle-wrapper.properties</strong> and update the <code>distributionUrl</code> to gradle 6.3:</p>
<p><code>distributionUrl=https\://services.gradle.org/distributions/gradle-6.3-all.zip</code></p>
<aside class="special"><p><strong>Note:</strong> For more information about this issue go to: <a href="https://youtrack.jetbrains.com/issue/KT-41604" target="_blank">Document that KMM Sample Project requires gradle 6.3 for jdk 14.0.2</a> on YouTrack.</p>
</aside>
<p>Now you can compile your project! On edit configurations on top of the page just select between Android and iOS - and have fun 🙌.</p>
<p class="image-container"><img style="width: 624.00px" src="img/3ed75e460c581937.png"></p>
</google-codelab-step>
<google-codelab-step label="Getting Started" duration="0">
<p>The source code of this codelab is available on GitHub. It follows the same structure of this Codelab. </p>
<p>Let's start! Open the content of <strong>Starter Project</strong> on Android Studio.</p>
<p><a href="https://github.com/cmota/kmm-a-multiplatform-adventure" target="_blank"><paper-button class="colored" raised>Download: A Multiplatform Adventure</paper-button></a></p>
<h2 is-upgraded>Compiling for Android Android and iOS</h2>
<p>For this codelab I've created a KMM (Kotlin Multiplatform Mobile) application for Android and iOS. Since we're targeting both platforms here you'll need to have installed:</p>
<ul>
<li>Android Studio with the KMM plugin or IntelliJ IDEA</li>
<li>Xcode or AppCode</li>
</ul>
<h2 is-upgraded><strong>Compiling only for Android</strong></h2>
<p>If you don't have Xcode/ AppCode installed you can still follow this tutorial! If you try to compile the project without having them, you'll see a similar error to this one:</p>
<pre>> Task :shared:cinteropFirebaseFirestoreIos FAILED
2 actionable tasks: 1 executed, 1 up-to-date
xcrun: error: unable to find utility "xcodebuild", not a developer tool or in PATH
Exception in thread "main" org.jetbrains.kotlin.konan.MissingXcodeException: An error occurred during an xcrun execution. Make sure that Xcode and its command line tools are properly installed.
at org.jetbrains.kotlin.konan.target.CurrentXcode.xcrun(Xcode.kt:77)
at org.jetbrains.kotlin.konan.target.CurrentXcode.access$xcrun(Xcode.kt:45)
at org.jetbrains.kotlin.konan.target.CurrentXcode$version$2.invoke(Xcode.kt:70)
at org.jetbrains.kotlin.konan.target.CurrentXcode$version$2.invoke(Xcode.kt:45)
at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:74)
at org.jetbrains.kotlin.konan.target.CurrentXcode.getVersion(Xcode.kt)
at org.jetbrains.kotlin.konan.target.AppleConfigurablesImpl$xcodePartsProvider$2.invoke(Apple.kt:71)
at org.jetbrains.kotlin.konan.target.AppleConfigurablesImpl$xcodePartsProvider$2.invoke(Apple.kt:24)
at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:74)
at org.jetbrains.kotlin.konan.target.AppleConfigurablesImpl.getXcodePartsProvider(Apple.kt)
at org.jetbrains.kotlin.konan.target.AppleConfigurablesImpl.getAbsoluteTargetToolchain(Apple.kt:48)</pre>
<p>To overcome this I've added a couple of comments on <strong>build.gradle.kts </strong>(the gradle configuration file inside the <strong>shared </strong>folder):</p>
<pre><code>/* If you don't have Xcode installed comment this code block*/</code></pre>
<p>That corresponds to the iOS compilation steps on kotlin multiplatform. Comment the code blocks that are under this phrase and click on compile again.</p>
<h2 is-upgraded><strong>Installing CocoaPods</strong></h2>
<p>On Mac is really simple to install CocoaPods, just open the terminal and execute the following command:</p>
<pre><code>sudo gem install cocoapods</code></pre>
<h2 is-upgraded><strong>Compiling the project</strong></h2>
<p>Now that you've got your environment ready, it's time to compile the project! Don't forget to download:</p>
<p><a href="https://github.com/cmota/kmm-a-multiplatform-adventure" target="_blank"><paper-button class="colored" raised>A Multiplatform Adventure</paper-button></a></p>
<ol type="1" start="1">
<li>Clone the project locally</li>
</ol>
<pre><code>git clone https://github.com/cmota/kmm-a-multiplatform-adventure.git</code></pre>
<ol type="1" start="2">
<li>Now open the <strong>Starter Project</strong> on Android Studio (with KMM plugin installed).</li>
</ol>
<ol type="1" start="3">
<li>You'll need to wait until gradle runs all the tasks. You can get up and stretch your legs a bit, this is going to take some minutes. </li>
</ol>
<p class="image-container"><img style="width: 624.00px" src="img/74ce0ce3faf133da.png"></p>
<ol type="1" start="4">
<li>Compile and run the app. You should see a screen similar to this one:</li>
</ol>
<p class="image-container"><img style="width: 190.50px" src="img/4449544640fad084.png"></p>
<ol type="1" start="5">
<li>Now it's to compile for iOS! First, go to the <strong>iosApp</strong> inside the <strong>Starter Project </strong>folder on the command line.</li>
</ol>
<pre><code>cd kmm-a-multiplatform-adventure/
cd Starter\ Project/
cd iosApp</code></pre>
<ol type="1" start="6">
<li>Enter <strong>pod install</strong> to install all the of its dependencies</li>
</ol>
<pre><code>pod install</code></pre>
<p>You should see a similar output to this one:</p>
<pre><code>Analyzing dependencies
Downloading dependencies
Installing BoringSSL-GRPC (0.0.7)
Installing FirebaseCore (6.10.3)
Installing FirebaseCoreDiagnostics (1.7.0)
Installing FirebaseFirestore (1.18.0)
Installing GoogleDataTransport (7.4.0)
Installing GoogleUtilities (6.7.2)
Installing PromisesObjC (1.2.10)
Installing abseil (0.20200225.0)
Installing gRPC-C++ (1.28.2)
Installing gRPC-Core (1.28.2)
Installing leveldb-library (1.22)
Installing nanopb (1.30906.0)
Installing shared (1.0-SNAPSHOT)
Generating Pods project
Integrating client project
Pod installation complete! There is 1 dependency from the Podfile and 13 total pods installed.</code></pre>
<ol type="1" start="7">
<li>Now that everything is installed let's open the <strong>iosApp.xcworkspace</strong> with Xcode.</li>
</ol>
<p class="image-container"><img style="width: 624.00px" src="img/e7492f591367870d.png"></p>
<aside class="special"><p><strong>Note:</strong> There are two project files inside the iosApp folder. It's easier to distinguish by the icon, the one that you need to open is the one that is white (extension <strong>.xcworkspace</strong>).</p>
</aside>
<ol type="1" start="8">
<li>Now click on compile and run and let's see the app running on the iOS simulator/ iPhone!</li>
</ol>
<p class="image-container"><img style="width: 421.50px" src="img/a9b735ab3e2cf745.png"></p>
</google-codelab-step>
<google-codelab-step label="Creating a logger" duration="0">
<p>I would say that most of us at one time or the other decided to implement their own logger. Since they are platform-specific, it seems to be the best way to dive in into kotlin multiplatform is to create our own logger - <code>Gutenberg</code>.</p>
<p>On Android we use:</p>
<p><code>Log.d</code>, <code>Log.w</code> and <code>Log.e</code> for debug, warnings and errors respectively.</p>
<p>and in iOS we typically use:</p>
<p><code>print</code> for everything that we want to log.</p>
<p>So, if our code is running on Android we want to use <code>Log</code> and if we're on iOS <code>print</code>. All of these without using a set of <code>if</code> and <code>else</code> conditions and ideally without shipping the other platform code which is just contributing to increment the dex count (and well, on Android this is quite problematic).</p>
<p>As a rule of thumb, when referring to code that needs to be defined at the platform level I usually use the prefix <code>Platform*</code>, this makes it easier to look for these particular classes.</p>
<p>Let's start by going to <strong>shared/src/commonMain/kotlin/<package_name></strong> and create the class <strong>PlatformLogger.kt</strong> which will contain all the logging methods for <code>Gutenberg</code>:</p>
<ul>
<li><code>debug</code></li>
<li><code>warn</code></li>
<li><code>error</code></li>
</ul>
<pre><code>package com.cmota.playground.alltogethernow.shared
internal expect class PlatformLogger {
fun debug(tag: String, message: String)
fun warn(tag: String, message: String)
fun error(tag: String, message: String)
}
object Gutenberg {
private val logger = PlatformLogger()
fun d(tag: String, message: String) {
logger.debug(tag, message)
}
fun w(tag: String, message: String) {
logger.warn(tag, message)
}
fun e(tag: String, message: String) {
logger.error(tag, message)
}
}</code></pre>
<p>You'll notice a keyword a bit different here - <code>expect</code>. </p>
<p>So, what are you <code>expect</code>-ing? 🤔</p>
<p>In order to define platform specific code on the <strong>shared</strong> module we use the keyword <code>expect</code>, this needs to later be defined on <strong>androidMain</strong> and <strong>iosMain</strong> with value, otherwise you won't be able to compile your project. This happens because the compiler itself has no clue what to <code>expect</code>, in this case what <strong>PlatformLogger.kt</strong> methods <code>debug</code>, <code>warn</code> and <code>error</code> are.</p>
<aside class="special"><p>Everytime you use the keyword <code>expect</code> you need to define the corresponding <code>actual</code> value on the platform folders. Otherwise you won't be able to compile the project.</p>
</aside>
<p>Navigating to <strong>shared/src/androidMain/kotlin/<package_name></strong> add a new kotlin class called the <strong>PlatformLogger.kt</strong>, where you're going to define what those methods should do when they're called from an Android application. Alternatively, you can move your cursor over <code>PlatformLogger</code> and press <strong>alt + enter</strong> to automatically generate the classes for the jvm and native.</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import android.util.Log
internal actual class PlatformLogger {
actual fun debug(tag: String, message: String) {
Log.d(tag, message)
}
actual fun warn(tag: String, message: String) {
Log.w(tag, message)
}
actual fun error(tag: String, message: String) {
Log.e(tag, message)
}
}</code></pre>
<p>Looking at the <code>import</code>, we can see that we're using code from the Android SDK itself, in this case we're using <code>Log</code>. So every time the Android app calls <code>Gutenberg.debug(String, String)</code> it will really be calling <code>Log.d(String, String)</code>. </p>
<aside class="special"><p><strong>Note:</strong> This calls can be made both from the Android app itself (Activities, Fragments, etc.) and from the shared module, for instance to output the list of conferences that we've received, as you're going to see in the next section.</p>
</aside>
<p>Now that we've got the Android platform code defined, we need to do the same thing for iOS. Go to <strong>shared/src/iosMain/kotlin/<package_name></strong> and add a new <strong>PlatformLogger.kt</strong> class.</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
internal actual class PlatformLogger {
actual fun debug(tag: String, message: String) {
print("$tag | $message")
}
actual fun warn(tag: String, message: String) {
print("$tag | $message")
}
actual fun error(tag: String, message: String) {
print("$tag | $message")
}
}</code></pre>
<p>Now if you want to print something from the iOS app, you can call directly:</p>
<p><code>Gutenberg().d(tag: String, message: String) </code></p>
<p>and it will be printed on the IDE console with the format "tag | message."</p>
<p>Now let's update all of our application logs to our newly created <code>Gutenberg</code> logger. Hit compile and exchange some messages to see them being printed on the console ! 🖨</p>
</google-codelab-step>
<google-codelab-step label="Fetching and parsing data" duration="0">
<p>I've scrapped and edited all the conferences from the <a href="https://androidstudygroup.github.io/conferences/" target="_blank">Android Study Group</a> into a nice json hosted on GitHub Gists that you can find <a href="https://gist.github.com/cmota/c6b15f54c9fed96750e5828b2f001249" target="_blank">here</a>. It has all the basic information about a conference:</p>
<ul>
<li>Name</li>
<li>City</li>
<li>Country</li>
<li>Date</li>
<li>Logo</li>
<li>Website</li>
<li>Status</li>
</ul>
<p>Where this last field represents if it's "online" or if it was "cancelled".</p>
<p>In this section our goal is to fetch this data, parse it and display it on both applications. Of course, all of this logic will be written only once.</p>
<aside class="special"><p><strong>Note:</strong> To avoid waiting for all the libraries to be downloaded the starter project already contains all that we're going to need. You can see this on <strong>build.gradle.kts</strong> inside the shared module.</p>
</aside>
<p>The libraries that are going to be used on this section are:</p>
<ul>
<li>io.ktor:ktor-client</li>
<li>kotlinx-serialization</li>
<li>kotlinx-coroutines</li>
</ul>
<p>If you go to <strong>src/commonMain/kotlin/<package_name>/data</strong> inside <strong>entities</strong> you'll see that there's a <strong>Conference.kt</strong> file with the following contents:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.data.entities
import kotlinx.serialization.Serializable
@Serializable
data class Conference (val name: String,
val city: String,
val country: String,
val date: String,
val logo: String,
val website: String,
val status: String) {
fun isCanceled() = status == "canceled"
}</code></pre>
<p>Typically, you would need to create this file. However, to avoid having to create all the accesses at the UI level to the fields here defined, it's already committed. It's important to mention that this class is responsible to hold all of the conference data. Now, let's define the source for our requests and how we can make them with <strong>ktor</strong>.</p>
<p>Create <strong>ConferencesAPI.k</strong> inside <strong>src/commonMain/kotlin/<package_name>/data</strong> and add the following code:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.request.*
private const val BASE_URL = "https://gist.githubusercontent.com/cmota/"
private const val ENDPOINT = "c6b15f54c9fed96750e5828b2f001249/raw/d7fc5e1b711107583959663056e6643f24ccae81/conferences.json"
class ConferencesAPI {
private val client = HttpClient()
suspend fun fetchConferences() = client.get<String>("$BASE_URL$ENDPOINT")
}</code></pre>
<p>If you're already familiarized with ktor you might see something a bit odd - where's the call to <code>install(JsonFeature)</code>? Typically we always need it if our response content type is <code>application/json</code> which it doesn't happen when we try to retrieve data from a Gist which is <code>text/plain</code>.</p>
<p>With this, the serialization won't work directly and this is why I've done it on <strong>GetConferences.kt</strong> class as you'll see in a second.</p>
<aside class="special"><p><strong>Note:</strong> We're currently using the version 1.4.1 of ktor. There are a couple of issues reported that you might get an <code>InvalidMutabilityException</code> when trying to parse a json file automatically. If this happens on your application, I advise you to roll back to ktor 1.4.0. For more information check this issue on YouTrack: <a href="https://youtrack.jetbrains.com/issue/KTOR-1087" target="_blank">"InvalidMutabilityException: Frozen during lazy computation" when using by lazy for HttpClient</a>.</p>
</aside>
<p>Now that we've got our network interface defined let's create our bridge with the domain layer. For this go to <strong>src/commonMain/kotlin/<package_name/domain</strong> and create the class <strong>GetConferences.kt</strong> with the following content:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.domain
import com.cmota.playground.alltogethernow.shared.Gutenberg
import com.cmota.playground.alltogethernow.shared.data.ConferencesAPI
import com.cmota.playground.alltogethernow.shared.data.entities.Conference
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
private const val TAG = "GetConferences"
class GetConferences(private val api: ConferencesAPI) {
suspend operator fun invoke(onSuccess: (List<Conference>) -> Unit, onFailure: (Exception) -> Unit) {
try {
//1
val result = api.fetchConferences()
//2
val conferences = Json.decodeFromString<List<Conference>>(result)
Gutenberg.d(TAG, "Result:$conferences")
coroutineScope {
//3
onSuccess(conferences)
}
} catch (e: Exception) {
coroutineScope {
//4
onFailure(e)
}
}
}
}</code></pre>
<p>You can see here that we're already using <code>Gutenberg</code> as our logger to log all the conferences that were retrieved. </p>
<aside class="warning"><p><strong>Note: </strong>Unable to find the <strong>Gutenberg</strong> reference? Go to the previous section, <strong>Creating a logger</strong> to implement it.</p>
</aside>
<p>Here's a step-by-step breakdown of this logic:</p>
<ol type="1" start="1">
<li>We're calling <code>fetchConferences()</code> that we've previously defined on <strong>ConferencesAPI.kt</strong>. It will be responsible to make the network requests, that we're going to parse on the next instruction.</li>
<li>Once we've got that data and since GitHub Gists response is always <code>text/plain</code> you'll need to parse it so we can have a list of all the conferences available.</li>
<li>In case everything worked as expected we're going to call <code>onSuccess</code> (received as parameter) that will later notify the UI that there's new data available.</li>
<li>If any of these steps fails, <code>onFailure</code> is called in opposite. This allows the UI to respond accordingly. An example of these failures, can be a request made when the device is not connected to the internet.</li>
</ol>
<p>Now let's go to the <strong>presentation</strong> layer and create the <strong>ConferenceListPresenter.kt</strong>. This file should be added into <strong>src/commonMain/kotlin/<package_name>/presentation</strong>.</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.presentation
import com.cmota.playground.alltogethernow.shared.domain.GetConferences
import com.cmota.playground.alltogethernow.shared.domain.defaultDispatcher
import com.cmota.playground.alltogethernow.shared.presentation.cb.IConferenceData
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
class ConferenceListPresenter(private val conferences: GetConferences,
private val coroutineContext: CoroutineContext = defaultDispatcher
) {
//1
private var view: IConferenceData? = null
private lateinit var scope: PresenterCoroutineScope
//2
fun attachView(currView: IConferenceData) {
view = currView
scope = PresenterCoroutineScope(coroutineContext)
fetchConferenceList()
}
//3
fun detachView() {
if (view == null) {
return
}
view = null
scope.viewDetached()
}
private fun fetchConferenceList() {
scope.launch {
//4
conferences(
onSuccess = { view?.onConferenceDataFetched(it) },
onFailure = { view?.onConferenceDataFailed(it) }
)
}
}
}</code></pre>
<p>This is the class that the UI (both Android and iOS) will call in order to get a list of conferences. Let's analyse it into further detail:</p>
<ol type="1" start="1">
<li>We have an interface defined here that we don't yet know (which makes sense since it's not created... yet!). This will later be used to call the respondent methods that are defined in the UI.</li>
<li>I've called this method <code>attachView</code> but it could easily be called <code>fetchConferences</code> for instance. The naming chosen here is just to be easier to understand when it should be called.</li>
<li>The same thing applies to <code>detachView</code> which will cancel and destroy all the references to the previous defined fields.</li>
<li>Now this is the important part. We've received an interface on <code>attachView</code>, and the <strong>ConferenceListPresenter.kt</strong> itself receives the <code>GetConferences</code> class that we've defined before. These two calls: <code>onSuccess</code> and <code>onFailure</code> represent the calls made inside invoke where we called the first one when new data was available and the second one when the operation failed. Here, this calls will be mapped into calling the interface <code>onConferenceDataFetched</code> and <code>onConferenceDataFailed</code> which will call the corresponding methods at the UI level.</li>
</ol>
<p>Now that we've been talking about the <strong>IConferenceData.kt </strong>interface, let's create it under <strong>src/commonMain/kotlin/<package_name>/presentation/cb</strong> and add the following definitions:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.presentation.cb
import com.cmota.playground.alltogethernow.shared.data.entities.Conference
interface IConferenceData {
fun onConferenceDataFetched(conferences: List<Conference>)
fun onConferenceDataFailed(e: Exception)
}</code></pre>
<p>We're almost there! There's only three more instructions that we need to add before calling them from the UI. Let's head up to <strong>ServiceLocator.kt</strong> and add the following declarations:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import com.cmota.playground.alltogethernow.shared.data.ConferencesAPI
import com.cmota.playground.alltogethernow.shared.domain.GetConferences
import com.cmota.playground.alltogethernow.shared.presentation.ConferenceListPresenter
import kotlin.native.concurrent.ThreadLocal
@ThreadLocal
object ServiceLocator {
//Message fields were already and are defined here
private val conferencesAPI by lazy { ConferencesAPI() }
private val getConferences: GetConferences
get() = GetConferences(conferencesAPI)
val getConferencePresenter: ConferenceListPresenter
get() = ConferenceListPresenter(getConferences)
}</code></pre>
<p>Now that we've got all the classes and methods defined we can jump in into the Android app and make these calls from there.</p>
<p>Let's go to <strong>androidApp/src/main/java/<package_name></strong> and look inside the <strong>fragments </strong>folder for the <strong>MessagesFragment.kt</strong> file. All the UI is already defined and created, however we don't have any call to get the conferences content. </p>
<p>Previously, you've see that the UI needed to declare an interface in order to be notified when there was new content available/when the request operation failed, so let's start by adding it to the implemented classes and define those two methods:</p>
<pre><code>package com.cmota.playground.alltogethernow.androidApp.fragments
import com.cmota.playground.alltogethernow.shared.ServiceLocator
import com.cmota.playground.alltogethernow.shared.data.entities.Conference
import com.cmota.playground.alltogethernow.shared.presentation.cb.IConferenceData
import com.cmota.playground.alltogethernow.shared.Gutenberg
private const val TAG = "ConferencesFragment"
//1
class ConferencesFragment : Fragment(), IConferenceData {
//2
private val presenterConferences by lazy { ServiceLocator.getConferencePresenter }
private fun setup() {
//Other UI instructions were omitted
//3
presenterConferences.attachView(this)
}
//region IConferenceData
//4
override fun onConferenceDataFetched(conferences: List<Conference>) {
Gutenberg.d(TAG, "New conference data fetched: $conferences")
val adapter = binding.rvConferences.adapter as ConferencesListAdapter
adapter.submitList(conferences)
}
override fun onConferenceDataFailed(e: Exception) {
Gutenberg.e(TAG, "Unable to retrieve conference data. Reason: $e")
}
//endregion IConferenceData
}</code></pre>
<p>Diving into detail into this newly added logic, you can see:</p>
<ol type="1" start="1">
<li>A new interface is added: <code>IConferenceData</code>. It's going to be used to notify the UI from the shared module when new information is available.</li>
<li><code>presenterConferences</code> is a field that corresponds to the <strong>ConferenceListPresenter</strong> that we've defined on the shared module.</li>
<li>From which we will call <code>attachView</code> which will be responsible for retrieving the conferences list from the server.</li>
<li>Finally, these two methods will be called depending on the success state of the request operation.</li>
</ol>
<p>Now let's hit compile and run the Android application! Go over to the conferences tab, you should see the list of the next events to attend 🤖.</p>
<p class="image-container"><img style="width: 254.30px" src="img/86784b5dd2b0779d.png"></p>
<p>It's now time to get back at the IDE and do the same calls for the iOS app. Let's navigate to <strong>iosApp/iosApp/ConferencesViewController.swift</strong> and define the following extension:</p>
<pre><code>extension ConferencesViewController: IConferenceData {
func onConferenceDataFailed(e: KotlinException) {
Gutenberg().w(tag: "onConferenceDataFailed", message: "Error:\(e)")
}
func onConferenceDataFetched(conferences: [Conference]) {
for conference in conferences {
Gutenberg().d(tag: "onConferenceDataFetched", message: conference.name)
}
self.conferences = conferences
self.tableView.reloadData()
}
}</code></pre>
<p>And now that everything is defined you just need to add the <code>PresenterConference</code> initialization and the calls to <code>attachView</code> to fetch the list and <code>detachView</code> to remove the subscription:</p>
<pre><code>class ConferencesViewController: UIViewController {
private let presenterConference = ServiceLocator.init().getConferencePresenter
override func viewDidAppear(_ animated: Bool) {
presenterConference.attachView(currView: self)
}
override func viewWillDisappear(_ animated: Bool) {
presenterConference.detachView()
}
}</code></pre>
<p>Now that we've got everything ready, compile and run the iOS app 🍏.</p>
<p class="image-container"><img style="width: 430.50px" src="img/e3e85d846b210e27.png"></p>
</google-codelab-step>
<google-codelab-step label="Storing data locally" duration="0">
<p>Now that we've implemented our network label, let's go to the next feature: store this data locally on a database. We want to show the latest data at all times, so if the request fails you'll return the information stored on the local database.</p>
<p>This is going to be done thanks to an amazing library called sqldelight.</p>
<p>Before starting let's look at our <strong>build.gradle.kts</strong> inside the shared module to see where the sqldelight is defined:</p>
<pre><code>plugins {
id("com.squareup.sqldelight")
}</code></pre>
<pre><code>sqldelight {
database("ConferenceDb") {
packageName = "data"
}
}</code></pre>
<p>Where we define the <strong>sq. </strong>database is going to be located so sqldelight can generate the corresponding classes and the <code>ConferenceDb</code> represents the generated file that's going to allow us to communicate with the database.</p>
<p>And let's start with this definition. Go to <strong>shared/src/commonMain/sqldelight/data </strong>and create a new directory called <strong>model</strong> and inside it <strong>ConferenceModel.sq</strong> with the following sql instructions so the corresponding database can be created and accessed.</p>
<pre><code>CREATE TABLE ConferenceModel (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
date TEXT NOT NULL,
logo TEXT NOT NULL,
website TEXT NOT NULL,
status TEXT NOT NULL
);
insertOrReplaceConference:
INSERT OR REPLACE INTO ConferenceModel(id, name, city, country, date, logo, website, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?);
selectAllConferences:
SELECT *
FROM ConferenceModel;</code></pre>
<p>Once you hit compile you'll see that a couple of files were generated under:</p>
<ul>
<li><strong>shared/build/generated/sqldelight/code/ConferenceDb</strong></li>
</ul>
<p>If you open for instance <strong>ConferenceDbImpl.kt</strong> you'll see all the instructions that you need to insert/update and get all conferences from the database.</p>
<p>At the current version, 1.4.3, sqldelight requires that you define platform dependent code in order to create your databases on the device. So let's start by creating our <strong>Platform*</strong> classes.</p>
<p>Go to the shared module directory: <strong>shared/src/commonMain/kotlin/<package_name></strong> and add the <strong>PlatformDatabase.kt</strong>.</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import data.ConferenceDb
expect class PlatformDatabase {
fun createDatabase(): ConferenceDb
}</code></pre>
<p>You already know how this works! You've said that you <code>expect</code> that the platforms would define what <code>PlatformDatabase</code> is, so let's go to:</p>
<ul>
<li><strong>shared/src/androidMain/kotlin/<package_name> </strong></li>
</ul>
<p>And create the <code>actual</code> implementation of <strong>PlatformDatabase.kt</strong> for Android:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import android.content.Context
import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver
import data.ConferenceDb
lateinit var appContext: Context
actual class PlatformDatabase {
actual fun createDatabase(): ConferenceDb {
return ConferenceDb(createDriver())
}
private fun createDriver(): SqlDriver {
return AndroidSqliteDriver(ConferenceDb.Schema, appContext, "appData.db")
}
}</code></pre>
<p>The local database is going to be called <code>appData.db</code>.</p>
<p>Looking close to the code it's possible to identify that it requires the application context in order to be created, which means that the Android app itself needs to define <code>appContext</code>. In order to do this, go over your android app under <strong>androidApp/src/main/java/<package_name></strong> and in <strong>AppApplication.kt</strong> add:</p>
<pre><code>package com.cmota.playground.alltogethernow.androidApp
import android.app.Application
import com.cmota.playground.alltogethernow.shared.appContext
class AppApplication : Application() {
override fun onCreate() {
super.onCreate()
appContext = this
}
}</code></pre>
<p>Since the application is the first class of the Android app to be loaded and we've defined <code>appContext</code> as <code>lateinit var</code> we need to initialize it on <code>onCreate</code>. This way we can guarantee that there won't be any accesses to the database without it being initialized.</p>
<ul>
<li><strong>shared/src/iosMain/kotlin/<package_name> </strong></li>
</ul>
<p>Now navigate to the <strong>iosMain</strong> directory and once again create the <code>actual</code> implementation of <strong>PlatformDatabase.kt</strong>:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import com.squareup.sqldelight.db.SqlDriver
import com.squareup.sqldelight.drivers.native.NativeSqliteDriver
import data.ConferenceDb
actual class PlatformDatabase {
actual fun createDatabase(): ConferenceDb {
return ConferenceDb(createDriver())
}
private fun createDriver(): SqlDriver {
return NativeSqliteDriver(ConferenceDb.Schema, "appData.db")
}
}</code></pre>
<p>And that's it for the iOS side.</p>
<p>Now let's get back to the shared module and create the <strong>ConferenceDAO.kt</strong> so we can easily access the generated <strong>ConferenceDb.kt</strong>. To do this go to the directory <strong>shared/src/commonMain/kotlin/<package_name>/domain/dao</strong> and add this new file.</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.domain.dao
import com.cmota.playground.alltogethernow.shared.data.entities.Conference
import data.ConferenceDb
class ConferenceDAO(database: ConferenceDb) {
private val db = database.conferenceModelQueries
internal fun insertOrReplace(conference: Conference) {
db.insertOrReplaceConference(
id = "${conference.name}-${conference.country}-${conference.date}",
name = conference.name,
city = conference.city,
country = conference.country,
date = conference.date,
logo = conference.logo,
website = conference.website,
status = conference.status)
}
internal fun getAllConferences(): List<Conference> {
val data = db.selectAllConferences().executeAsList()
val conferences = mutableListOf<Conference>()
for (item in data) {
conferences += Conference(
item.name,
item.city,
item.country,
item.date,
item.logo,
item.website,
item.status)
}
return conferences
}
}</code></pre>
<p>This will get the fields defined on the <code>Conference</code> data class and either add them to the database via <code>insertOrReplace</code> or retrieve them from there on <code>getAllConferences</code>.</p>
<p>Now that we've got all the platform dependent code defined, open once again <strong>GetConferences.kt</strong> and let's store on the local database the conferences retrieved from the server:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.domain
import com.cmota.playground.alltogethernow.shared.Gutenberg
import com.cmota.playground.alltogethernow.shared.data.ConferencesAPI
import com.cmota.playground.alltogethernow.shared.data.entities.Conference
import com.cmota.playground.alltogethernow.shared.domain.dao.ConferenceDAO
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
private const val TAG = "GetConferences"
//1
class GetConferences(private val api: ConferencesAPI, private val dao: ConferenceDAO) {
suspend operator fun invoke(onSuccess: (List<Conference>) -> Unit, onFailure: (Exception) -> Unit) {
try {
val result = api.fetchConferences()
val conferences = Json.decodeFromString<List<Conference>>(result)
Gutenberg.d(TAG, "Result:$conferences")
//2
for (conference in conferences) {
dao.insertOrReplace(conference)
}
coroutineScope {
onSuccess(conferences)
}
} catch (e: Exception) {
coroutineScope {
//3
val conferences = dao.getAllConferences()
if (conferences.isEmpty()) {
onFailure(e)
} else {
onSuccess(conferences)
}
}
}
}
}</code></pre>
<p>Let's analyze this code step by step:</p>
<ol type="1" start="1">
<li>You'll now receive the <strong>ConferenceDAO</strong> that you've previously created. This means that there's another update that we need to do before being able to compile the project again - create this object and send it on the call.</li>
<li>Once there's new data available we're going to add it to the local database. Alternatively, you could just create a method that would receive that list and make that iteration there.</li>
<li>If there was any problem during this operation the app will fallback to the existing data already added to the database. </li>
</ol>
<aside class="special"><p><strong>Note:</strong> There's another advantage on sharing this code across both platforms - consistency. Since everything is defined on the shared module, we can guarantee that the app will behave the same way either if it's running on an Android or an iOS. The days where the features didn't match will soon be a memory.</p>
</aside>
<p>Finally, let's open the <strong>ServiceLocator.kt</strong> and declare the <code>ConferenceDAO</code> object and update the call to <code>GetConferences()</code>:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import com.cmota.playground.alltogethernow.shared.data.ConferencesAPI
import com.cmota.playground.alltogethernow.shared.domain.GetConferences
import com.cmota.playground.alltogethernow.shared.domain.dao.ConferenceDAO
import com.cmota.playground.alltogethernow.shared.presentation.ConferenceListPresenter
import kotlin.native.concurrent.ThreadLocal
@ThreadLocal
object ServiceLocator {
private val conferencesAPI by lazy { ConferencesAPI() }
private val conferenceDao by lazy { ConferenceDAO(PlatformDatabase().createDatabase()) }
private val getConferences: GetConferences
get() = GetConferences(conferencesAPI, conferenceDao)
val getConferencePresenter: ConferenceListPresenter
get() = ConferenceListPresenter(getConferences)
}</code></pre>
<p>Now let's hit compile and run and let's see everything working 🚀. </p>
<aside class="special"><p><strong>Note:</strong> To test this feature you can:</p>
<ol type="1" start="1">
<li>Open the conferences tab on your app.</li>
<li>Once the data is fetched kill the app and turn off your WiFi/cellular network. </li>
<li>Open the app again and go to the conferences tab.</li>
</ol>
<p>You should have all the conferences previously downloaded there.</p>
</aside>
</google-codelab-step>
<google-codelab-step label="Using multiplatform settings" duration="0">
<p>Let's dive into another library! This time the multiplatform-settings. Briefly, it gives you the possibility to use Android's <strong>SharedPreferences</strong> or iOS <strong>NSUserDefaults</strong> depending on the platform that you're currently using.</p>
<p>This is already added on <strong>build.gradle.kts</strong> from the shared module, on the <code>commonMain</code> <code>dependencies</code> declaration:</p>
<pre><code>implementation("com.russhwolf:multiplatform-settings:0.6.2")</code></pre>
<p>Let's start by creating the <strong>PlatformSettings.kt</strong> that we are <code>expect</code>-ing. Similar to other <strong>Platform*</strong> files that you're creating, it should be added to <strong>shared/src/commonMain/kotlin/<package_name>.</strong></p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import com.cmota.playground.alltogethernow.shared.data.SettingsRepository
expect object PlatformSettings {
val settingsRepository: SettingsRepository
fun createSettingsRepository(): SettingsRepository
}</code></pre>
<p>And now the actual implementation on <strong>shared/src/androidMain/kotlin/<package_name></strong>: </p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import androidx.preference.PreferenceManager
import com.cmota.playground.alltogethernow.shared.data.SettingsRepository
import com.russhwolf.settings.AndroidSettings
actual object PlatformSettings {
actual val settingsRepository : SettingsRepository by lazy {
createSettingsRepository()
}
actual fun createSettingsRepository(): SettingsRepository {
val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(appContext)
val settings = AndroidSettings(sharedPrefs)
return SettingsRepository(settings)
}
}</code></pre>
<p>That is going to use Android's <strong>SharedPreferences</strong> to store the required content, in this case the application settings.</p>
<p>Now let's add the equivalent file to <strong>shared/src/iosMain/kotlin/<package_name></strong>:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared
import com.cmota.playground.alltogethernow.shared.data.SettingsRepository
import com.russhwolf.settings.AppleSettings
import platform.Foundation.NSUserDefaults
actual object PlatformSettings {
actual val settingsRepository : SettingsRepository by lazy {
createSettingsRepository()
}
actual fun createSettingsRepository(): SettingsRepository {
return SettingsRepository(AppleSettings(NSUserDefaults.standardUserDefaults))
}
}</code></pre>
<p>Which uses <strong>NSUserDefaults</strong>.</p>
<p>Now that we've got our settings defined, we're going to use them in two places:</p>
<ul>
<li>To save the current state of the online setting</li>
<li>To save the user defined username</li>
</ul>
<p>So let's create these two settings on the shared module. Create a <strong>SettingsRepository.kt</strong> on <strong>shared/src/commonMain/kotlin/<package_name>/data</strong> and add:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.data
import com.cmota.playground.alltogethernow.shared.deviceName
import com.russhwolf.settings.Settings
private const val SETTING_ONLY_ONLINE = "setting_show_only_online"
private const val SETTING_MY_USERNAME = "setting_my_username"
class SettingsRepository(private val settings: Settings) {
private val appSettings: Settings = createAppSettings(settings)
private fun createAppSettings(settings: Settings): Settings {
settings.putString(SETTING_MY_USERNAME, deviceName())
settings.putBoolean(SETTING_ONLY_ONLINE, false)
return settings
}
fun getUsername() = appSettings.getString(SETTING_MY_USERNAME, deviceName())
fun setUsername(username: String) {
appSettings.putString(SETTING_MY_USERNAME, username)
}
fun shouldShowOnlyOnlineConferences() = appSettings.getBoolean(SETTING_ONLY_ONLINE, false)
fun onlyOnlineConferences(state: Boolean) {
appSettings.putBoolean(SETTING_ONLY_ONLINE, state)
}
}</code></pre>
<p>Now that we've got both settings defined, let's first open the <strong>GetConferences.kt</strong> class, that's under <strong>shared/src/commonMain/kotlin/<package_name>/domain</strong>, and add this logic to filter only for only conferences when this setting is enabled:</p>
<pre><code>package com.cmota.playground.alltogethernow.shared.domain
import com.cmota.playground.alltogethernow.shared.PlatformSettings.settingsRepository
class GetConferences(private val api: ConferencesAPI, private val dao: ConferenceDAO) {
suspend operator fun invoke(onSuccess: (List<Conference>) -> Unit, onFailure: (Exception) -> Unit) {
try {
// Request and parse logic
//Check current state of toggle only online
val availableConferences = if (settingsRepository.shouldShowOnlyOnlineConferences()) {
conferences.filter { !it.isCanceled() }
} else {
conferences
}
coroutineScope {
onSuccess(availableConferences)
}
} catch (e: Exception) {
// ...
}
}
}</code></pre>
<p>Finally, let's navigate to the android app by going to <strong>androidApp/src/main/java/<package_name>/Fragments</strong> and open the <strong>SettingsFragment.kt</strong>, so the user can change these values.</p>
<p>Here we've got the <code>setup</code> method that's waiting for it's content to be defined:</p>
<pre><code>private fun setup() {
binding.cbOnline.isChecked = settingsRepository.shouldShowOnlyOnlineConferences()
binding.cbOnline.setOnCheckedChangeListener { _, isChecked ->
settingsRepository.onlyOnlineConferences(isChecked)
}
binding.etUsername.setText(settingsRepository.getUsername())
binding.etUsername.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
val text = binding.etUsername.text.toString()
if (text.isNotEmpty()) {
settingsRepository.setUsername(text)
}
}
}
}</code></pre>
<p>And in <strong>MessagesFragment.kt</strong>, update the <code>loadMessages()</code> and <code>sendMessages()</code> methods to instead of using the <code>deviceName()</code> as the username, retrieve the value stored on settings.</p>
<pre><code>private fun setup() {
binding.cbOnline.isChecked = settingsRepository.shouldShowOnlyOnlineConferences()