l+u&&a.scrollIntoView(!1)})});const o={route:n,sidebarItems:r,VPSidebarItem:Nb};return Object.defineProperty(o,"__isScriptSetup",{enumerable:!1,value:!0}),o}}),Mb={key:0,class:"vp-sidebar-items"};function Hb(e,t,n,r,o,s){return r.sidebarItems.length?(Y(),ne("ul",Mb,[(Y(!0),ne(_e,null,nn(r.sidebarItems,i=>(Y(),Le(r.VPSidebarItem,{key:`${i.text}${i.link}`,item:i},null,8,["item"]))),128))])):He("",!0)}const Fb=ke(Vb,[["render",Hb],["__file","VPSidebarItems.vue"]]),Bb=me({__name:"VPSidebar",setup(e,{expose:t}){t();const n={VPNavbarItems:Yf,VPSidebarItems:Fb};return Object.defineProperty(n,"__isScriptSetup",{enumerable:!1,value:!0}),n}}),$b={class:"vp-sidebar","vp-sidebar":""};function Ub(e,t,n,r,o,s){return Y(),ne("aside",$b,[ae(r.VPNavbarItems),Re(e.$slots,"top"),ae(r.VPSidebarItems),Re(e.$slots,"bottom")])}const jb=ke(Bb,[["render",Ub],["__file","VPSidebar.vue"]]),zb=me({__name:"Layout",setup(e,{expose:t}){t();const n=Qn(),r=At(),o=Fe(),s=H(()=>r.value.navbar!==!1&&o.value.navbar!==!1),i=Vi(),a=de(!1),l=m=>{a.value=typeof m=="boolean"?m:!a.value},u={x:0,y:0},f=m=>{u.x=m.changedTouches[0].clientX,u.y=m.changedTouches[0].clientY},c=m=>{const E=m.changedTouches[0].clientX-u.x,V=m.changedTouches[0].clientY-u.y;Math.abs(E)>Math.abs(V)&&Math.abs(E)>40&&(E>0&&u.x<=80?l(!0):l(!1))},d=H(()=>r.value.externalLinkIcon??o.value.externalLinkIcon??!0),p=H(()=>[{"no-navbar":!s.value,"no-sidebar":!i.value.length,"sidebar-open":a.value,"external-link-icon":d.value},r.value.pageClass]);let _;Je(()=>{_=Tn().afterEach(()=>{l(!1)})}),Lo(()=>{_()});const v=Ff(),y=v.resolve,b=v.pending,A={page:n,frontmatter:r,themeLocale:o,shouldShowNavbar:s,sidebarItems:i,isSidebarOpen:a,toggleSidebar:l,touchStart:u,onTouchStart:f,onTouchEnd:c,enableExternalLinkIcon:d,containerClass:p,get unregisterRouterHook(){return _},set unregisterRouterHook(m){_=m},scrollPromise:v,onBeforeEnter:y,onBeforeLeave:b,VPHome:ry,VPNavbar:Xy,VPPage:Ib,VPSidebar:jb};return Object.defineProperty(A,"__isScriptSetup",{enumerable:!1,value:!0}),A}});function Kb(e,t,n,r,o,s){return Y(),ne("div",{class:nt(["vp-theme-container",r.containerClass]),"vp-container":"",onTouchstart:r.onTouchStart,onTouchend:r.onTouchEnd},[Re(e.$slots,"navbar",{},()=>[r.shouldShowNavbar?(Y(),Le(r.VPNavbar,{key:0,onToggleSidebar:r.toggleSidebar},{before:Ve(()=>[Re(e.$slots,"navbar-before")]),after:Ve(()=>[Re(e.$slots,"navbar-after")]),_:3})):He("",!0)]),ie("div",{class:"vp-sidebar-mask",onClick:t[0]||(t[0]=i=>r.toggleSidebar(!1))}),Re(e.$slots,"sidebar",{},()=>[ae(r.VPSidebar,null,{top:Ve(()=>[Re(e.$slots,"sidebar-top")]),bottom:Ve(()=>[Re(e.$slots,"sidebar-bottom")]),_:3})]),Re(e.$slots,"page",{},()=>[r.frontmatter.home?(Y(),Le(r.VPHome,{key:0})):(Y(),Le(fi,{key:1,name:"fade-slide-y",mode:"out-in",onBeforeEnter:r.onBeforeEnter,onBeforeLeave:r.onBeforeLeave},{default:Ve(()=>[(Y(),Le(r.VPPage,{key:r.page.path},{top:Ve(()=>[Re(e.$slots,"page-top")]),"content-top":Ve(()=>[Re(e.$slots,"page-content-top")]),"content-bottom":Ve(()=>[Re(e.$slots,"page-content-bottom")]),bottom:Ve(()=>[Re(e.$slots,"page-bottom")]),_:3}))]),_:3},8,["onBeforeEnter","onBeforeLeave"]))])],34)}const Wb=ke(zb,[["render",Kb],["__file","Layout.vue"]]),qb=me({__name:"NotFound",setup(e,{expose:t}){t();const n=Kr(),r=Fe(),o=r.value.notFound??["Not Found"],s=()=>o[Math.floor(Math.random()*o.length)],i=r.value.home??n.value,a=r.value.backToHome??"Back to home",l={routeLocale:n,themeLocale:r,messages:o,getMsg:s,homeLink:i,homeText:a,get RouteLink(){return Fo}};return Object.defineProperty(l,"__isScriptSetup",{enumerable:!1,value:!0}),l}}),Gb={class:"vp-theme-container","vp-container":""},Yb={class:"page"},Xb={class:"theme-default-content","vp-content":""};function Zb(e,t,n,r,o,s){return Y(),ne("div",Gb,[ie("main",Yb,[ie("div",Xb,[t[0]||(t[0]=ie("h1",null,"404",-1)),ie("blockquote",null,Ie(r.getMsg()),1),ae(r.RouteLink,{to:r.homeLink},{default:Ve(()=>[on(Ie(r.homeText),1)]),_:1},8,["to"])])])])}const Jb=ke(qb,[["render",Zb],["__scopeId","data-v-67c08c1d"],["__file","NotFound.vue"]]),Qb=un({enhance({app:e,router:t}){Xc("Badge")||e.component("Badge",V1);const n=t.options.scrollBehavior;t.options.scrollBehavior=async(...r)=>(await Ff().wait(),n(...r))},setup(){S1(),L1()},layouts:{Layout:Wb,NotFound:Jb}}),eE=Object.freeze(Object.defineProperty({__proto__:null,default:Qb},Symbol.toStringTag,{value:"Module"})),so=[m_,w_,R_,D_,q_,Q_,nv,lv,y1,eE].map(e=>e.default).filter(Boolean),tE=JSON.parse('{"base":"/learning-kotlin-multiplatform/","lang":"en-US","title":"","description":"","head":[["link",{"rel":"icon","href":"/learning-kotlin-multiplatform/favicon.ico"}],["link",{"rel":"manifest","href":"/learning-kotlin-multiplatform/manifest.webmanifest"}],["meta",{"name":"theme-color","content":"#bf4092"}]],"locales":{}}');var hr=Pt(tE),nE=Ym,rE=()=>{const e=kg({history:nE(Ec("/learning-kotlin-multiplatform/")),routes:[{name:"vuepress-route",path:"/:catchAll(.*)",components:{}}],scrollBehavior:(t,n,r)=>r||(t.hash?{el:t.hash}:{top:0})});return e.beforeResolve(async(t,n)=>{if(t.path!==n.path||n===Nt){const r=Ir(t.fullPath);if(r.path!==t.fullPath)return r.path;const o=await r.loader();t.meta={...r.meta,_pageChunk:o}}else t.path===n.path&&(t.meta=n.meta)}),e},oE=e=>{e.component("ClientOnly",_i),e.component("Content",vi),e.component("RouteLink",Fo)},sE=(e,t,n)=>{const r=H(()=>t.currentRoute.value.path),o=$d((y,b)=>({get(){return y(),t.currentRoute.value.meta._pageChunk},set(A){t.currentRoute.value.meta._pageChunk=A,b()}})),s=H(()=>hn.resolveLayouts(n)),i=H(()=>hn.resolveRouteLocale(hr.value.locales,r.value)),a=H(()=>hn.resolveSiteLocaleData(hr.value,i.value)),l=H(()=>o.value.comp),u=H(()=>o.value.data),f=H(()=>u.value.frontmatter),c=H(()=>hn.resolvePageHeadTitle(u.value,a.value)),d=H(()=>hn.resolvePageHead(c.value,f.value,a.value)),p=H(()=>hn.resolvePageLang(u.value,a.value)),_=H(()=>hn.resolvePageLayout(u.value,s.value)),v={layouts:s,pageData:u,pageComponent:l,pageFrontmatter:f,pageHead:d,pageHeadTitle:c,pageLang:p,pageLayout:_,redirects:Ds,routeLocale:i,routePath:r,routes:Fn,siteData:hr,siteLocaleData:a};return e.provide(mi,v),Object.defineProperties(e.config.globalProperties,{$frontmatter:{get:()=>f.value},$head:{get:()=>d.value},$headTitle:{get:()=>c.value},$lang:{get:()=>p.value},$page:{get:()=>u.value},$routeLocale:{get:()=>i.value},$site:{get:()=>hr.value},$siteLocale:{get:()=>a.value},$withBase:{get:()=>yi}}),v},iE=([e,t,n=""])=>{const r=Object.entries(t).map(([a,l])=>bt(l)?`[${a}=${JSON.stringify(l)}]`:l?`[${a}]`:"").join(""),o=`head > ${e}${r}`;return Array.from(document.querySelectorAll(o)).find(a=>a.innerText===n)??null},aE=([e,t,n])=>{if(!bt(e))return null;const r=document.createElement(e);return di(t)&&Object.entries(t).forEach(([o,s])=>{bt(s)?r.setAttribute(o,s):s&&r.setAttribute(o,"")}),bt(n)&&r.appendChild(document.createTextNode(n)),r},lE=()=>{const e=Rg(),t=Lg();let n=[];const r=()=>{e.value.forEach(i=>{const a=iE(i);a&&n.push(a)})},o=()=>{const i=[];return e.value.forEach(a=>{const l=aE(a);l&&i.push(l)}),i},s=()=>{document.documentElement.lang=t.value;const i=o();n.forEach((a,l)=>{const u=i.findIndex(f=>a.isEqualNode(f));u===-1?(a.remove(),delete n[l]):i.splice(u,1)}),i.forEach(a=>document.head.appendChild(a)),n=[...n.filter(a=>!!a),...i]};An(Vg,s),Je(()=>{r(),Ue(e,s,{immediate:!1})})},uE=Yh,cE=async()=>{var r;const e=uE({name:"Vuepress",setup(){var i;lE();for(const a of so)(i=a.setup)==null||i.call(a);const o=so.flatMap(({rootComponents:a=[]})=>a.map(l=>ve(l))),s=Dg();return()=>[ve(s.value),o]}}),t=rE();oE(e);const n=sE(e,t,so);{const{setupDevtools:o}=await pt(async()=>{const{setupDevtools:s}=await import("./setupDevtools-7MC2TMWH-CzOqVNI-.js");return{setupDevtools:s}},[]);o(e,n)}for(const o of so)await((r=o.enhance)==null?void 0:r.call(o,{app:e,router:t,siteData:hr}));return e.use(t),{app:e,router:t}};cE().then(({app:e,router:t})=>{t.isReady().then(()=>{e.mount("#app")})});export{ke as _,lh as a,ie as b,ne as c,cE as createVueApp,Y as o,I0 as s,Ue as w};
diff --git a/assets/apps-BKESpmvK.png b/assets/apps-BKESpmvK.png
new file mode 100644
index 0000000..22dd4c7
Binary files /dev/null and b/assets/apps-BKESpmvK.png differ
diff --git a/assets/avatar-Bf9zzubu.png b/assets/avatar-Bf9zzubu.png
new file mode 100644
index 0000000..9899730
Binary files /dev/null and b/assets/avatar-Bf9zzubu.png differ
diff --git a/assets/data_layer-i0YTWCrI.png b/assets/data_layer-i0YTWCrI.png
new file mode 100644
index 0000000..0ae2380
Binary files /dev/null and b/assets/data_layer-i0YTWCrI.png differ
diff --git a/assets/diagramme_sql-CFrbOXnm.png b/assets/diagramme_sql-CFrbOXnm.png
new file mode 100644
index 0000000..c63b58f
Binary files /dev/null and b/assets/diagramme_sql-CFrbOXnm.png differ
diff --git a/assets/hello_desktop-XdizPvgx.png b/assets/hello_desktop-XdizPvgx.png
new file mode 100644
index 0000000..1319f97
Binary files /dev/null and b/assets/hello_desktop-XdizPvgx.png differ
diff --git a/assets/index.html-B_ORBeHs.js b/assets/index.html-B_ORBeHs.js
new file mode 100644
index 0000000..1efa9aa
--- /dev/null
+++ b/assets/index.html-B_ORBeHs.js
@@ -0,0 +1,113 @@
+import{_ as s,o as a,c as t,a as e,b as p}from"./app-DaulDPFL.js";const o={};function i(l,n){return a(),t("div",null,n[0]||(n[0]=[e(`Kstore is a tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialization and kotlinx.io. Inspired by RxStore
Add kstore dependency to your project for each target platform
build.gradle.kts (composeMain) commonMain. dependencies {
+ .. .
+ implementation ( libs. kstore)
+ }
+ androidMain. dependencies {
+ .. .
+ implementation ( libs. kstore. file)
+ }
+ desktopMain. dependencies {
+ .. .
+ implementation ( libs. kstore. file)
+ }
+ iosMain. dependencies {
+ implementation ( libs. kstore. file)
+ }
+ wasmJsMain. dependencies {
+ implementation ( libs. kstore. storage)
+ }
+ .. .
+
Define the native call to get the kstore instance
platform.kt (commonMain) expect fun getKStore ( ) : KStore< Quiz> ?
+
Define each platform call to get the kstore instance for Android, iOS, Web, Desktop
platform.kt (androidMain) actual fun getKStore ( ) : KStore< Quiz> ? {
+ return storeOf ( QuizApp. context ( ) . dataDir. path. plus ( "/quiz.json" ) . toPath ( ) )
+ }
+
Also Android needs context to instanciate the kstore. Without injection library, you can use an App context singleton.
QuizApp.kt (androidMain) class QuizApp : Application ( ) {
+ init {
+ app = this
+ }
+
+ companion object {
+ private lateinit var app: QuizApp
+ fun context ( ) : Context = app. applicationContext
+ }
+}
+
Add the QuizApp to the AndroidManifest.xml
AndroidManifest.xml (androidMain) ...
+ <application
+ android:name=".QuizApp"
+...
+
platform.kt (iosMain) @OptIn ( ExperimentalKStoreApi:: class )
+ actual fun getKStore ( ) : KStore< Quiz> ? {
+ return NSFileManager. defaultManager. DocumentDirectory? . relativePath? . plus ( "/quiz.json" ) ? . toPath ( ) ? . let {
+ storeOf (
+ file= it
+ )
+ }
+ }
+
platform.kt (wasmJsMain) actual fun getKStore ( ) : KStore< Quiz> ? {
+ return storeOf ( key = "kstore_quiz" )
+ }
+
+
platform.kt (desktopMain) actual fun getKStore ( ) : KStore< Quiz> ? {
+ return storeOf ( "quiz.json" . toPath ( ) )
+ }
+
+
Upgrade the Quiz object with an update timestamp
Quiz.kt (commonMain) @Serializable
+data class Quiz ( var questions: List< Question> , val updateTime: Long= 0L )
+
Create a QuizKStoreDataSource class to store the kstore data
QuizKStoreDataSource.kts (commonMain) class QuizKStoreDataSource {
+ private val kStoreQuiz: KStore< Quiz> ? = getKStore ( )
+ suspend fun getUpdateTimeStamp ( ) : Long = kStoreQuiz? . get ( ) ? . updateTime ?: 0L
+
+ suspend fun setUpdateTimeStamp ( timeStamp: Long) {
+ kStoreQuiz? . update { quiz: Quiz? ->
+ quiz? . copy ( updateTime = timeStamp)
+ }
+ }
+
+ suspend fun getAllQuestions ( ) : List< Question> {
+ return kStoreQuiz? . get ( ) ? . questions ?: emptyList ( )
+ }
+
+ suspend fun insertQuestions ( newQuestions: List< Question> ) {
+ kStoreQuiz? . update { quiz: Quiz? ->
+ quiz? . copy ( questions = newQuestions)
+ }
+ }
+
+ suspend fun resetQuizKstore ( ) {
+ kStoreQuiz? . delete ( )
+ kStoreQuiz? . set ( Quiz ( emptyList ( ) , 0L ) )
+ }
+}
+
Update the QuizRepository class to use the kstore
QuizRepository.kts (commonMain) class QuizRepository {
+ private val mockDataSource = MockDataSource ( )
+ private val quizApiDatasource = QuizApiDatasource ( )
+ private var quizKStoreDataSource = QuizKStoreDataSource ( )
+
+ private suspend fun fetchQuiz ( ) : List< Question> = quizApiDatasource. getAllQuestions ( ) . questions
+
+ private suspend fun fetchAndStoreQuiz ( ) : List< Question> {
+ quizKStoreDataSource. resetQuizKstore ( )
+ val questions = fetchQuiz ( )
+ quizKStoreDataSource. insertQuestions ( questions)
+ quizKStoreDataSource. setUpdateTimeStamp ( Clock. System. now ( ) . epochSeconds)
+ return questions
+ }
+
+ suspend fun updateQuiz ( ) : List< Question> {
+ try {
+ val lastRequest = quizKStoreDataSource. getUpdateTimeStamp ( )
+ return if ( lastRequest == 0L || lastRequest - Clock. System. now ( ) . epochSeconds > 300000 ) {
+ fetchAndStoreQuiz ( )
+ } else {
+ quizKStoreDataSource. getAllQuestions ( )
+ }
+ } catch ( e: NullPointerException) {
+ return fetchAndStoreQuiz ( )
+ } catch ( e: Exception) {
+ e. printStackTrace ( )
+ return mockDataSource. generateDummyQuestionsList ( )
+ }
+ }
+
+}
+
Sources
The full sources can be retrieved here
`,24),p("iframe",{width:"560",height:"315",src:"https://youtube.com/embed/r-wUqYZgbOo",title:"KMP Quiz App overview",frameborder:"0",allow:"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",allowfullscreen:""},null,-1)]))}const u=s(o,[["render",i],["__file","index.html.vue"]]),r=JSON.parse('{"path":"/preferences/","title":"Preferences","lang":"en-US","frontmatter":{"description":"Preferences Kstore is a tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialization and kotli...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/preferences/"}],["meta",{"property":"og:title","content":"Preferences"}],["meta",{"property":"og:description","content":"Preferences Kstore is a tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialization and kotli..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T21:20:21.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T21:20:21.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"Preferences\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-04T21:20:21.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"🎬 Summary video of the course","slug":"🎬-summary-video-of-the-course","link":"#🎬-summary-video-of-the-course","children":[]}],"git":{"updatedTime":1728076821000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":15,"url":"https://github.com/Ibrahim Gharbi"}]},"filePathRelative":"preferences/README.md","autoDesc":true}');export{u as comp,r as data};
diff --git a/assets/index.html-Bl1WRIzJ.js b/assets/index.html-Bl1WRIzJ.js
new file mode 100644
index 0000000..f8a4b4d
--- /dev/null
+++ b/assets/index.html-Bl1WRIzJ.js
@@ -0,0 +1 @@
+import{_ as t,c as o,a as r,o as a}from"./app-DaulDPFL.js";const i="/learning-kotlin-multiplatform/assets/logo_worldline-t5KadDQv.png",n="/learning-kotlin-multiplatform/assets/avatar-Bf9zzubu.png",l={};function s(h,e){return a(),o("div",null,e[0]||(e[0]=[r('👍 You like the course ? Give us a star on Github ⭐
We design payments technology that powers the growth of millions of businesses around the world. Engineering the next frontiers in payments technology
Leader in payment and secured transactions. Over 50bn transactions/year 7000+ engineers in over 40 countries A huge & diverse tech-stack Gharbi Ibrahim 🔗 @__brah 🔗 cv.gharbi.org
',10)]))}const p=t(l,[["render",s],["__file","index.html.vue"]]),d=JSON.parse('{"path":"/","title":"KMP | Tech at Worldline","lang":"en-US","frontmatter":{"home":true,"heroImage":"./logo.png","heroText":"Hands-on Lab | KMP with compose","tagline":"Discover Kotlin multiplatform features in practice (Android, iOS, desktop & web).","actions":[{"text":"Start →","link":"/overview/","type":"primary"}],"features":[{"title":"1. Configure KMP","details":"Shared library principles for Android,iOS and Desktop jvm"},{"title":"2. Composables","details":"Kotlin compose & declarative UI, state management"},{"title":"3. Navigation","details":"Compose navigation, navigation hist"},{"title":"4. Ressources","details":"Compose image, string , fonts, raw resources"},{"title":"5. Architecture","details":"ViewModel, repository, coroutines"},{"title":"6. Network connectivity","details":"Ktor client/server configuration, repository creation with flow"},{"title":"7. Preferences","details":"Shared preferences, file storage"},{"title":"8. Database","details":"SQLite with SQLDelight"}],"footer":"Worldline © 2023 | tech at Worldline","description":"KMP | Tech at Worldline 👍 You like the course ? Give us a star on Github ⭐ Who we are avatar We design payments technology that powers the growth of millions of businesses aro...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/"}],["meta",{"property":"og:title","content":"KMP | Tech at Worldline"}],["meta",{"property":"og:description","content":"KMP | Tech at Worldline 👍 You like the course ? Give us a star on Github ⭐ Who we are avatar We design payments technology that powers the growth of millions of businesses aro..."}],["meta",{"property":"og:type","content":"website"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T21:38:00.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T21:38:00.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"WebPage\\",\\"name\\":\\"KMP | Tech at Worldline\\",\\"description\\":\\"KMP | Tech at Worldline 👍 You like the course ? Give us a star on Github ⭐ Who we are avatar We design payments technology that powers the growth of millions of businesses aro...\\"}"]]},"headers":[{"level":2,"title":"Who we are","slug":"who-we-are","link":"#who-we-are","children":[{"level":3,"title":"Follow trainers of this Hands-on Lab","slug":"follow-trainers-of-this-hands-on-lab","link":"#follow-trainers-of-this-hands-on-lab","children":[]},{"level":3,"title":"Follow our Tech team","slug":"follow-our-tech-team","link":"#follow-our-tech-team","children":[]}]}],"git":{"updatedTime":1728077880000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":13,"url":"https://github.com/Ibrahim Gharbi"}]},"filePathRelative":"index.md","autoDesc":true}');export{p as comp,d as data};
diff --git a/assets/index.html-C2rGOrLv.js b/assets/index.html-C2rGOrLv.js
new file mode 100644
index 0000000..644a2d3
--- /dev/null
+++ b/assets/index.html-C2rGOrLv.js
@@ -0,0 +1,34 @@
+import{_ as s,o as a,c as e,a as t}from"./app-DaulDPFL.js";const i={};function o(p,n){return a(),e("div",null,n[0]||(n[0]=[t(`For common code, store your resource files in the resources directory of the commonMain source set. For platform-specific code, store your resource files in the resources directory of the corresponding source set. Jetbrain release his experimental API painterResource
from org.jetbrains.compose.resource
package
@ExperimentalResourceApi
+@Composable
+public fun painterResource (
+ res: String
+) : Painter
+
Return a Painter from the given resource path. Can load either a BitmapPainter for rasterized images (.png, .jpg) or a VectorPainter for XML Vector Drawables (.xml).XML Vector Drawables have the same format as for Android (https://developer.android.com/reference/android/graphics/drawable/VectorDrawable) except that external references to Android resources are not supported.Note that XML Vector Drawables are not supported for Web target currently. To make your resources accessible from the resource library, use the following configuration in your build.gradle.kts file:
android {
+
+ sourceSets[ "main" ] . resources. srcDirs ( "src/commonMain/resources" )
+}
+
The Compose Multiplatform Gradle plugin handles resource deployment. The plugin stores resource files in the compose-resources directory of the resulting application bundle.
val commonMain by getting {
+ dependencies {
+
+ @OptIn ( org. jetbrains. compose. ExperimentalComposeLibrary:: class )
+ implementation ( compose. components. resources)
+ }
+}
+
Nothing to do for desktop App
Image (
+ painterResource ( "compose-multiplatform.xml" ) ,
+ null
+)
+
For more ressource management possibilities for font and String management, you can use a third party lib :
@OptIn ( ExperimentalResourceApi:: class )
+@Composable
+fun App ( ) {
+ var text: String? by remember { mutableStateOf ( null ) }
+
+ LaunchedEffect ( Unit) {
+ text = String ( resource ( "welcome.txt" ) . readBytes ( ) )
+ }
+
+ text? . let {
+ Text ( it)
+ }
+}
+
✅ If everything is fine, congrats, you've just finish this codelab. You can now experiment your kotlin skills eveywhere !
`,22)]))}const l=s(i,[["render",o],["__file","index.html.vue"]]),c=JSON.parse('{"path":"/res/","title":"Ressources","lang":"en-US","frontmatter":{"description":"Ressources For common code, store your resource files in the resources directory of the commonMain source set. For platform-specific code, store your resource files in the resou...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/res/"}],["meta",{"property":"og:title","content":"Ressources"}],["meta",{"property":"og:description","content":"Ressources For common code, store your resource files in the resources directory of the commonMain source set. For platform-specific code, store your resource files in the resou..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T10:17:15.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T10:17:15.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"Ressources\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-04T10:17:15.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"Images","slug":"images","link":"#images","children":[]},{"level":2,"title":"Fonts and String","slug":"fonts-and-string","link":"#fonts-and-string","children":[]},{"level":2,"title":"Other ressources","slug":"other-ressources","link":"#other-ressources","children":[]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1728037035000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":1,"url":"https://github.com/Ibrahim Gharbi"}]},"filePathRelative":"res/README.md","autoDesc":true}');export{l as comp,c as data};
diff --git a/assets/index.html-CIZ6Tn1I.js b/assets/index.html-CIZ6Tn1I.js
new file mode 100644
index 0000000..64687d9
--- /dev/null
+++ b/assets/index.html-CIZ6Tn1I.js
@@ -0,0 +1,131 @@
+import{_ as s,o as a,c as e,a as t}from"./app-DaulDPFL.js";const p="/learning-kotlin-multiplatform/assets/routes-BuqzzffU.png",o={};function i(l,n){return a(),e("div",null,n[0]||(n[0]=[t(`Compose multiplatform navigation library enable a navigation with navigation host
gradle.build.kts (module : composeApp) .. .
+ commonMain. dependencies {
+
+ plugins {
+ .. .
+ alias ( libs. plugins. kotlinSerialization)
+ }
+ commonMain. dependencies {
+ .. .
+ implementation ( libs. kotlin. navigation)
+ implementation ( libs. ktor. serialization. kotlinx. json)
+
+.. .
+
The navigation host is the configuration class that defines routes of your application.
Routes are path between all the composable screens that you will call later on your app.
For this Hands-on Lab we need 3 routes for :
At startup to the WelcomeScreen
from Welcome screen to the QuizScreen
from the final question QuizScreen
to the ScoreScreen
App.kt (SourceSet: commonMain)
+.. .
+import kotlinx. serialization. Serializable
+
+val questions = listOf (
+ Question (
+ 1 ,
+ "Android is a great platform ?" ,
+ 1 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ ) ,
+ Question (
+ 1 ,
+ "Android is a bad platform ?" ,
+ 2 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ )
+)
+
+@Serializable
+object WelcomeRoute
+
+@Serializable
+object QuizRoute
+
+@Serializable
+data class ScoreRoute ( val score: Int, val questionSize: Int)
+
+@Composable
+fun App (
+ navController: NavHostController = rememberNavController ( )
+) {
+
+ MaterialTheme {
+ NavHost (
+ navController = navController,
+ startDestination = WelcomeRoute,
+ ) {
+ composable< WelcomeRoute> {
+ welcomeScreen (
+ onStartButtonPushed = {
+ navController. navigate ( route = QuizRoute)
+ }
+ )
+ }
+ composable< QuizRoute> {
+ questionScreen (
+ questions = questions,
+
+ onFinishButtonPushed = {
+ score: Int, questionSize: Int -> navController. navigate ( route = ScoreRoute ( score, questionSize) )
+ }
+ )
+ }
+ composable< ScoreRoute> { backStackEntry ->
+ val scoreRoute: ScoreRoute = backStackEntry. toRoute< ScoreRoute> ( )
+ scoreScreen (
+ score = scoreRoute. score,
+ total = scoreRoute. questionSize,
+ onResetButtonPushed = {
+ navController. navigate ( route = QuizRoute)
+ }
+ )
+ }
+ }
+ }
+}
+
Warning
As you can see all composables now take as parameter a navigator. It will be needed to navigate with routes between screens.
for example, the WelcomeScreen
composable is now declared as follows :
@Composable ( )
+fun welcomeScreen ( navigator: Navigator) {
+ .. .
+
+
Use onStartButtonPushed
declared on screen instantiation in the NavHost
on welcome screen buttons click
WelcomeScreen.kt (SourceSet: commonMain) fun welcomeScreen ( onStartButtonPushed: ( ) -> Unit) {
+.. .
+
+ Button (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ onClick = { onStartButtonPushed ( ) }
+ ) {
+.. .
+
The same can be done for other screens
QuestionScreen.kt (commonMain)
fun questionScreen ( questions: List< Question> , onFinishButtonPushed: ( Int, Int) -> Unit) {
+..
+Button (
+ modifier = Modifier. padding ( bottom = 20 . dp) ,
+ onClick = {
+
+ if ( getPlatform ( ) . name == "WASM" ) {
+ onSaveStatQuestion (
+ questions[ questionProgress] . id,
+ questions[ questionProgress] . label,
+ selectedAnswer,
+ questions[ questionProgress] . correctAnswerId,
+ questions[ questionProgress] . answers[ selectedAnswer. toInt ( ) - 1 ] . label
+ )
+ }
+
+ if ( selectedAnswer == questions[ questionProgress] . correctAnswerId) {
+ score++
+ }
+ if ( questionProgress < questions. size - 1 ) {
+ questionProgress++
+ selectedAnswer = 1
+ } else {
+ onFinishButtonPushed ( score, questions. size)
+ }
+ }
+}
+.. .
+
ScoreScreen.kt (SourceSet : commonMain)
+fun scoreScreen ( score: Int, total: Int, onResetButtonPushed: ( ) -> Unit) {
+.. .
+ Button (
+ modifier = Modifier. padding ( all = 20 . dp) ,
+ onClick = {
+ onResetButtonPushed ( )
+ }
+ )
+.. .
+
Sources
The full solution for this section is availabe here
✅ If everything is fine, congrats, you've just finish this codelab. You can now experiment your kotlin skills eveywhere !
`,23)]))}const u=s(o,[["render",i],["__file","index.html.vue"]]),r=JSON.parse('{"path":"/nav/","title":"Navigation","lang":"en-US","frontmatter":{"description":"Navigation 🧪 Create Navigation between composable screens Compose multiplatform navigation library enable a navigation with navigation host Add Navigation dependency to your pr...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/nav/"}],["meta",{"property":"og:title","content":"Navigation"}],["meta",{"property":"og:description","content":"Navigation 🧪 Create Navigation between composable screens Compose multiplatform navigation library enable a navigation with navigation host Add Navigation dependency to your pr..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-11-15T10:53:15.000Z"}],["meta",{"property":"article:modified_time","content":"2024-11-15T10:53:15.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"Navigation\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-11-15T10:53:15.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"🧪 Create Navigation between composable screens","slug":"🧪-create-navigation-between-composable-screens","link":"#🧪-create-navigation-between-composable-screens","children":[]},{"level":2,"title":"🎯 Solutions","slug":"🎯-solutions","link":"#🎯-solutions","children":[]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1731667995000,"contributors":[{"name":"Brah","email":"brah.gharbi@gmail.com","commits":1,"url":"https://github.com/Brah"},{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":14,"url":"https://github.com/Ibrahim Gharbi"},{"name":"A187839","email":"ibrahim.gharbi@worldline.com","commits":10,"url":"https://github.com/A187839"}]},"filePathRelative":"nav/README.md","autoDesc":true}');export{u as comp,r as data};
diff --git a/assets/index.html-CJwk2asI.js b/assets/index.html-CJwk2asI.js
new file mode 100644
index 0000000..c77734a
--- /dev/null
+++ b/assets/index.html-CJwk2asI.js
@@ -0,0 +1,215 @@
+import{_ as s,o as a,c as e,a as p}from"./app-DaulDPFL.js";const t="/learning-kotlin-multiplatform/assets/diagramme_sql-CFrbOXnm.png",i={};function l(o,n){return a(),e("div",null,n[0]||(n[0]=[p(`Deprecated section
SQL delight
is for now no more compatible with the new default WASM template for WebApp application.
If you still want to use it you can revert to the old Js(IR) template.
Notice that for now this is the only Web target compatible database library for KMP
SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.
SQLDelight understands your existing SQL schema.
CREATE TABLE hockey_player (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL ,
+ number INTEGER NOT NULL
+) ;
+
It generates typesafe code for any labeled SQL statements.
Warning
Be carefull with SQL Delight , the project and his dependancies just move from com.squareup.sqldelight.*
to app.cash.sqldelight.*
Pay attention also with beta, alpha version of Android studio that could produce bugs on gradle task management for code generation of SQL Delight databases.
Refer to the multiplatform implementation of SQLDelight in official Github pages 👉 https://cashapp.github.io/sqldelight/2.0.0/multiplatform_sqlite/
plugins {
+.. .
+ id ( "app.cash.sqldelight" ) version "2.0.0"
+}
+.. .
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:runtime:2.0.0" )
+ implementation ( "app.cash.sqldelight:coroutines-extensions:2.0.0" )
+ implementation ( "org.jetbrains.kotlinx:kotlinx-datetime:0.4.1" )
+
+ }
+ }
+ val androidMain by getting {
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:android-driver:2.0.0" )
+
+ }
+ }
+ .. .
+ val iosMain by creating {
+ .. .
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:native-driver:2.0.0" )
+ }
+ }
+ val desktopMain by getting {
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:sqlite-driver:2.0.0" )
+ }
+ }
+ .. .
+
Your repository handle the following cases :
If there is no network and it's the first time launch of the app : handle and error if there is no network and you have db datas : return on the flow the db data if there is network and db data are younger than 5 min : return on the flow the db data if there is network and db data are older than 5 min : retourn on the flow the network data and reset db data QuizDatabase.sq (ressources of commonMain)* CREATE TABLE update_time (
+ timestamprequest INTEGER
+) ;
+
+INSERT INTO update_time( timestamprequest) VALUES ( 0 ) ;
+
+CREATE TABLE questions (
+ id INTEGER PRIMARY KEY ,
+ label TEXT NOT NULL ,
+ correctAnswerId INTEGER NOT NULL
+ ) ;
+
+
+ CREATE TABLE answers (
+ id INTEGER NOT NULL ,
+ label TEXT NOT NULL ,
+ question_id INTEGER NOT NULL ,
+ PRIMARY KEY ( id, question_id) ,
+ FOREIGN KEY ( question_id)
+ REFERENCES questions ( id)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+ ) ;
+
+
+
+ selectUpdateTimestamp:
+ SELECT *
+ FROM update_time;
+
+ insertTimeStamp:
+ INSERT INTO update_time( timestamprequest)
+ VALUES ( :timestamp ) ;
+
+ deleteTimeStamp:
+ DELETE FROM update_time;
+
+ deleteQuestions:
+ DELETE FROM questions;
+
+ deleteAnswers:
+ DELETE FROM answers;
+
+
+ selectAllQuestionsWithAnswers:
+ SELECT *
+ FROM questions
+ INNER JOIN answers ON questions. id = answers. question_id;
+
+ insertQuestion:
+ INSERT INTO questions( id, label, correctAnswerId)
+ VALUES ( ?, ?, ?) ;
+
+ insertAnswer:
+ INSERT INTO answers( id, label, question_id)
+ VALUES ( ?, ?, ?) ;
+
+
network/QuizDB.kt (commonMain) package network
+
+
+import app. cash. sqldelight. async. coroutines. awaitAsList
+import app. cash. sqldelight. async. coroutines. awaitAsOneOrNull
+import app. cash. sqldelight. db. SqlDriver
+import com. myapplication. common. cache. Database
+import kotlinx. coroutines. CoroutineScope
+import network. data. Answer
+import network. data. Question
+
+class QuizDbDataSource ( private val sqlDriver: SqlDriver, private val coroutineScope: CoroutineScope) {
+
+ private var database= Database ( sqlDriver)
+ private var quizQueries= database. quizDatabaseQueries
+
+
+ suspend fun getUpdateTimeStamp ( ) : Long = quizQueries. selectUpdateTimestamp ( ) . awaitAsOneOrNull ( ) ? . timestamprequest ?: 0L
+
+
+ suspend fun setUpdateTimeStamp ( timeStamp: Long) {
+ quizQueries. deleteTimeStamp ( )
+ quizQueries. insertTimeStamp ( timeStamp)
+ }
+
+ suspend fun getAllQuestions ( ) : List< Question> {
+ return quizQueries. selectAllQuestionsWithAnswers ( ) . awaitAsList ( )
+
+ . groupBy { it. question_id }
+ . map { ( questionId, rowList) ->
+
+ Question (
+ id = questionId,
+ label = rowList. first ( ) . label,
+ correctAnswerId = rowList. first ( ) . correctAnswerId,
+ answers = rowList. map { answer ->
+ Answer (
+ id = answer. id_,
+ label = answer. label_
+ )
+ }
+ )
+ }
+ }
+
+
+
+ suspend fun insertQuestions ( questions: List< Question> ) {
+ quizQueries. deleteQuestions ( ) ;
+ quizQueries. deleteAnswers ( )
+ questions. forEach { question ->
+ quizQueries. insertQuestion ( question. id, question. label, question. correctAnswerId)
+ question. answers. forEach { answer ->
+ quizQueries. insertAnswer ( answer. id, answer. label, question. id)
+ }
+ }
+ }
+}
+
QuizRepository.kt package network
+
+import app. cash. sqldelight. db. SqlDriver
+import kotlinx. coroutines. CoroutineScope
+import kotlinx. coroutines. Dispatchers
+import kotlinx. coroutines. flow. MutableStateFlow
+import kotlinx. coroutines. flow. update
+import kotlinx. coroutines. launch
+import kotlinx. datetime. Clock
+import network. data. Question
+
+
+class QuizRepository ( sqlDriver: SqlDriver) {
+
+ private val mockDataSource = MockDataSource ( )
+ private val quizAPI = QuizApiDatasource ( )
+ private val coroutineScope = CoroutineScope ( Dispatchers. Main)
+ private var quizDB = QuizDbDataSource ( sqlDriver, coroutineScope)
+
+ private var _questionState= MutableStateFlow ( listOf< Question> ( ) )
+ var questionState = _questionState
+
+ init {
+ updateQuiz ( )
+ }
+
+ private suspend fun fetchQuiz ( ) : List< Question> = quizAPI. getAllQuestions ( ) . questions
+
+ private suspend fun fetchAndStoreQuiz ( ) : List< Question> {
+ val questions = fetchQuiz ( )
+ quizDB. insertQuestions ( questions)
+ quizDB. setUpdateTimeStamp ( Clock. System. now ( ) . epochSeconds)
+ return questions
+ }
+ private fun updateQuiz ( ) {
+
+
+ coroutineScope. launch {
+ _questionState. update {
+ try {
+ val lastRequest = quizDB. getUpdateTimeStamp ( )
+ if ( lastRequest == 0L || lastRequest - Clock. System. now ( ) . epochSeconds > 300000 ) {
+ fetchAndStoreQuiz ( )
+ } else {
+ quizDB. getAllQuestions ( )
+ }
+ } catch ( e: NullPointerException) {
+ fetchAndStoreQuiz ( )
+ } catch ( e: Exception) {
+ e. printStackTrace ( )
+ mockDataSource. generateDummyQuestionsList ( )
+ }
+
+ }
+ }
+ }
+}
+
✅ If everything is fine, go to the next chapter →
`,27)]))}const u=s(i,[["render",l],["__file","index.html.vue"]]),r=JSON.parse('{"path":"/database/","title":"(Local Database)","lang":"en-US","frontmatter":{"description":"(Local Database) Deprecated section SQL delight is for now no more compatible with the new default WASM template for WebApp application. If you still want to use it you can reve...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/database/"}],["meta",{"property":"og:title","content":"(Local Database)"}],["meta",{"property":"og:description","content":"(Local Database) Deprecated section SQL delight is for now no more compatible with the new default WASM template for WebApp application. If you still want to use it you can reve..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T13:49:37.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T13:49:37.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"(Local Database)\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-04T13:49:37.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"🧪 Add sqldelight db to your quizz","slug":"🧪-add-sqldelight-db-to-your-quizz","link":"#🧪-add-sqldelight-db-to-your-quizz","children":[]},{"level":2,"title":"🎯 Solutions","slug":"🎯-solutions","link":"#🎯-solutions","children":[]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1728049777000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":2,"url":"https://github.com/Ibrahim Gharbi"},{"name":"A187839","email":"ibrahim.gharbi@worldline.com","commits":5,"url":"https://github.com/A187839"}]},"filePathRelative":"database/README.md","autoDesc":true}');export{u as comp,r as data};
diff --git a/assets/index.html-CyMmZTR2.js b/assets/index.html-CyMmZTR2.js
new file mode 100644
index 0000000..68e771d
--- /dev/null
+++ b/assets/index.html-CyMmZTR2.js
@@ -0,0 +1,141 @@
+import{_ as a,o as e,c as t,b as n,a as p}from"./app-DaulDPFL.js";const i="/learning-kotlin-multiplatform/assets/data_layer-i0YTWCrI.png",l={};function o(c,s){return e(),t("div",null,s[0]||(s[0]=[n("h1",{id:"architecture",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#architecture"},[n("span",null,"Architecture")])],-1),n("p",null,"Let's connect our Quiz app to internet.",-1),n("h2",{id:"overview",tabindex:"-1"},[n("a",{class:"header-anchor",href:"#overview"},[n("span",null,"Overview")])],-1),n("div",{class:"hint-container tip"},[n("p",{class:"hint-container-title"},"Architecture basics"),n("p",null,[n("strong",null,"Everything You NEED to Know About MVVM Architecture Patterns")]),n("iframe",{width:"560",height:"315",src:"https://www.youtube.com/embed/I5c7fBgvkNY",title:"Everything You NEED to Know About Client Architecture Patterns",frameborder:"0",allow:"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",allowfullscreen:""})],-1),p('Data layer in KMP is under building but largly inspired by Android Architecture pattern
Repository classes are responsible for the following tasks:
Exposing data to the rest of the app. Centralizing changes to the data. Resolving conflicts between multiple data sources. Abstracting sources of data from the rest of the app. Containing business logic. "A flow is an asynchronous data stream that sequentially emits values and completes normally or with an exception."
There are multiple types of flow, for the Hands-on Lab, we will focus on StateFlow
A state flow is a hot flow because its active instance exists independently of the presence of collectors (our composables that consume the data)
"A coroutine is an instance of suspendable computation. It is conceptually similar to a thread, in the sense that it takes a block of code to run that works concurrently with the rest of the code. However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one."
Create a mock datasource, that generate a list of question Use it with a repository Use the repository on the root of your application ( navHost in App.kt) Add coroutine dependancy to your project.
build.gradle.kts (commonMain) commonMain. dependencies {
+ .. .
+ implementation ( libs. kotlinx. coroutines. core)
+ }
+
MockDataSource.kt package com. worldline. quiz. data. datasources
+
+class MockDataSource {
+
+ fun generateDummyQuestionsList ( ) : List< Question> {
+ return listOf (
+ Question (
+ 1 ,
+ "Android is a great platform ?" ,
+ 1 ,
+ listOf (
+ Answer ( 1 , "YES" ) ,
+ Answer ( 2 , "NO" )
+ )
+ ) ,
+ Question (
+ 1 ,
+ "Android is a bad platform ?" ,
+ 2 ,
+ listOf (
+ Answer ( 1 , "YES" ) ,
+ Answer ( 2 , "NO" )
+ )
+ )
+ )
+ }
+
+}
+
QuizRepository.kt package com. worldline. quiz. data
+
+class QuizRepository ( ) {
+
+ private val mockDataSource = MockDataSource ( )
+ private val coroutineScope = CoroutineScope ( Dispatchers. Main)
+ private var _questionState= MutableStateFlow ( listOf< Question> ( ) )
+ var questionState = _questionState
+
+ init {
+ updateQuiz ( )
+ }
+
+ private fun updateQuiz ( ) {
+ coroutineScope. launch {
+ _questionState. update {
+ mockDataSource. generateDummyQuestionsList ( )
+ }
+ }
+ }
+}
+
App.kt .. .
+@Composable
+fun App (
+ navController: NavHostController = rememberNavController ( ) ,
+ quizRepository: QuizRepository = QuizRepository ( )
+) {
+
+ MaterialTheme {
+ NavHost (
+ navController = navController,
+ startDestination = WelcomeRoute,
+ ) {
+
+
+ composable< WelcomeRoute> ( ) {
+ welcomeScreen (
+ onStartButtonPushed = {
+ navController. navigate ( route = QuizRoute)
+ }
+ )
+ }
+ composable< QuizRoute> ( ) {
+ val questions by quizRepository. questionState. collectAsState ( )
+ questionScreen (
+ questions = questions,
+
+ onFinishButtonPushed = {
+ score: Int, questionSize: Int -> navController. navigate ( route = ScoreRoute ( score, questionSize) )
+ }
+ )
+ }
+ composable< ScoreRoute> { backStackEntry ->
+ val scoreRoute: ScoreRoute = backStackEntry. toRoute< ScoreRoute> ( )
+ scoreScreen (
+ score = scoreRoute. score,
+ total = scoreRoute. questionSize,
+ onResetButtonPushed = {
+ navController. navigate ( route = QuizRoute)
+ }
+ )
+ }
+ }
+ }
+}
+
Sources
The full solution for this section is availabe here
Create a ViewModel class Upgrade the repository that is no more storing the flow and move it to the ViewModel Upgrade the App to use the ViewModel instead of the Repository gradle.build.kts (module : composeApp) .. .
+ commonMain. dependencies {
+ .. .
+ implementation ( libs. androidx. lifecycle. viewmodel. compose)
+.. .
+
QuizViewModel.kt package com. worldline. quiz
+
+class QuizViewModel : ViewModel ( ) {
+ private var quizRepository: QuizRepository = QuizRepository ( )
+ private var _questionState = MutableStateFlow ( listOf< Question> ( ) )
+ var questionState: StateFlow< List< Question> > = _questionState
+
+
+ val questionState : StateFlow<List<Question>>
+ field = MutableStateFlow(listOf<Question>())
+ -> in build.gradle.kts : sourceSets.all { languageSettings.enableLanguageFeature("ExplicitBackingFields") }
+ */
+
+ init {
+ getQuestionQuiz ( )
+ }
+
+ private fun getQuestionQuiz ( ) {
+ viewModelScope. launch ( Dispatchers. Default) {
+ _questionState. update {
+ quizRepository. updateQuiz ( )
+ }
+ }
+ }
+}
+
QuizRepository.kt class QuizRepository {
+ private val mockDataSource = MockDataSource ( )
+ fun updateQuiz ( ) : List< Question> {
+ return mockDataSource. generateDummyQuestionsList ( )
+ }
+}
+
App.kt fun App (
+ navController: NavHostController = rememberNavController ( ) ,
+ quizViewModel: QuizViewModel = QuizViewModel ( )
+) {
+.. .
+composable ( route = QuizRoute) {
+ val questions by quizViewModel. questionState. collectAsState ( )
+
Sources
The full solution for this section is availabe here
✅ If everything is fine, go to the next chapter →
`,30)]))}const r=a(l,[["render",o],["__file","index.html.vue"]]),d=JSON.parse(`{"path":"/arch/","title":"Architecture","lang":"en-US","frontmatter":{"description":"Architecture Let's connect our Quiz app to internet. Overview Architecture basics Everything You NEED to Know About MVVM Architecture Patterns Data layer for KMP Data layer in K...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/arch/"}],["meta",{"property":"og:title","content":"Architecture"}],["meta",{"property":"og:description","content":"Architecture Let's connect our Quiz app to internet. Overview Architecture basics Everything You NEED to Know About MVVM Architecture Patterns Data layer for KMP Data layer in K..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-07T09:29:37.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-07T09:29:37.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"Architecture\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-07T09:29:37.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"Overview","slug":"overview","link":"#overview","children":[{"level":3,"title":"Data layer for KMP","slug":"data-layer-for-kmp","link":"#data-layer-for-kmp","children":[]},{"level":3,"title":"Kotlin flow","slug":"kotlin-flow","link":"#kotlin-flow","children":[]},{"level":3,"title":"Coroutine","slug":"coroutine","link":"#coroutine","children":[]}]},{"level":2,"title":"🧪 DataSource and Repository","slug":"🧪-datasource-and-repository","link":"#🧪-datasource-and-repository","children":[{"level":3,"title":"🎯 Solutions","slug":"🎯-solutions","link":"#🎯-solutions","children":[]}]},{"level":2,"title":"🧪 ViewModel","slug":"🧪-viewmodel","link":"#🧪-viewmodel","children":[]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1728293377000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":5,"url":"https://github.com/Ibrahim Gharbi"}]},"filePathRelative":"arch/README.md","autoDesc":true}`);export{r as comp,d as data};
diff --git a/assets/index.html-DBoeKgiV.js b/assets/index.html-DBoeKgiV.js
new file mode 100644
index 0000000..842ba1b
--- /dev/null
+++ b/assets/index.html-DBoeKgiV.js
@@ -0,0 +1,14 @@
+import{_ as n,o as a,c as s,a as t}from"./app-DaulDPFL.js";const r="/learning-kotlin-multiplatform/assets/apps-BKESpmvK.png",o="/learning-kotlin-multiplatform/assets/overview2-gDQlEEdM.png",i="/learning-kotlin-multiplatform/assets/screens-p5CGXVR7.png",l={};function p(c,e){return a(),s("div",null,e[0]||(e[0]=[t(`Basic knowledge of kotlin development (nullability,inline & lambda functions mainly). For more information, please refer to the Worldline kotlin training Fleet IDE is the dedicated IDE to consider for KMP developpement with exclusive features such as better preview managementA good connectivity Advanced installation
For more information about your DEV environment and installs please have a look to jetbrain related docs
Consider also installing Jetbrain ToolBox for managing multiple versions ( Beta , Alpha , stable) of Android Studio or Fleet
Definition:
FP uses an approach to software development that uses pure functions to create maintainable software It uses immutable functions and avoids shared states. It is in contrast to object-oriented programming languages which uses mutable states It focuses on results and not process, while the iterations like for loops are not allowed Advantages: Problems are easy Keeps concurrency safe
let numbers = [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 ]
+
+
+let oddNumbers = numbers. filter { $0 % 2 != 0 }
+
+
+let squaredNumbers = oddNumbers. map { $0 * $0 }
+
+
+let sumOfSquares = squaredNumbers. reduce ( 0 , + )
+
+print ( "The sum of squares of odd numbers is \\( sumOfSquares ) " )
+
+
Filter : filters array to include only odd numbers Map : squares the numbers Reduce : sums up the squared numbers
This example demonstrates the core principles of functional programming: using functions as first-class citizens to transform and compose data in a clear and concise way.
See the roadmap 2024 on official Jetbrain blog
We will create a simple quiz application that provides :
a Startup screen explaining rules of the game a Quiz screen looping on single choices questions a final scoring screen. The app can be deployed on Android , iOS and jvm Desktop. We will use not only a common library but composable views shared for all platforms Here are expected screens at the end of this Hands-on Lab.
Generate composables based on designs
You can generate composables based on designs on Figma thanks to the plugin Google Relay . A dedicated section on android developer documentation describe all the steps here
',29)]))}const m=n(l,[["render",p],["__file","index.html.vue"]]),d=JSON.parse(`{"path":"/overview/","title":"🚀 Let's start","lang":"en-US","frontmatter":{"description":"🚀 Let's start Prerequisites Basic knowledge of kotlin development (nullability,inline & lambda functions mainly). For more information, please refer to the Worldline kotlin tra...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/overview/"}],["meta",{"property":"og:title","content":"🚀 Let's start"}],["meta",{"property":"og:description","content":"🚀 Let's start Prerequisites Basic knowledge of kotlin development (nullability,inline & lambda functions mainly). For more information, please refer to the Worldline kotlin tra..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-11-15T10:53:15.000Z"}],["meta",{"property":"article:modified_time","content":"2024-11-15T10:53:15.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"🚀 Let's start\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-11-15T10:53:15.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"Prerequisites","slug":"prerequisites","link":"#prerequisites","children":[{"level":3,"title":"What is Functional Programming?","slug":"what-is-functional-programming","link":"#what-is-functional-programming","children":[]},{"level":3,"title":"Apps using KMP","slug":"apps-using-kmp","link":"#apps-using-kmp","children":[]},{"level":3,"title":"KMP roadmap","slug":"kmp-roadmap","link":"#kmp-roadmap","children":[]}]},{"level":2,"title":"Hands-on Lab objectives","slug":"hands-on-lab-objectives","link":"#hands-on-lab-objectives","children":[{"level":3,"title":"Functionnally","slug":"functionnally","link":"#functionnally","children":[]},{"level":3,"title":"Technically","slug":"technically","link":"#technically","children":[]},{"level":3,"title":"Design screens","slug":"design-screens","link":"#design-screens","children":[]}]}],"git":{"updatedTime":1731667995000,"contributors":[{"name":"Brah","email":"brah.gharbi@gmail.com","commits":1,"url":"https://github.com/Brah"},{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":16,"url":"https://github.com/Ibrahim Gharbi"},{"name":"A187839","email":"ibrahim.gharbi@worldline.com","commits":8,"url":"https://github.com/A187839"}]},"filePathRelative":"overview/README.md","autoDesc":true}`);export{m as comp,d as data};
diff --git a/assets/index.html-IZAD4WfO.js b/assets/index.html-IZAD4WfO.js
new file mode 100644
index 0000000..764b3d3
--- /dev/null
+++ b/assets/index.html-IZAD4WfO.js
@@ -0,0 +1,198 @@
+import{_ as s,o as a,c as t,a as e}from"./app-DaulDPFL.js";const p="/learning-kotlin-multiplatform/assets/toolbox-DbC4d27p.png",o="/learning-kotlin-multiplatform/assets/plugins_install-BKgRePQf.png",i="/learning-kotlin-multiplatform/assets/kmp_sample_src-CoII6UwA.png",l="/learning-kotlin-multiplatform/assets/template-DXB9rnv0.png",c="/learning-kotlin-multiplatform/assets/project_struct-DMA9UCBx.png",r="/learning-kotlin-multiplatform/assets/run-BxGtZnDA.png",u="/learning-kotlin-multiplatform/assets/hello_desktop-XdizPvgx.png",k={};function d(m,n){return a(),t("div",null,n[0]||(n[0]=[e('Fleet IDE is the dedicated IDE to consider for KMP developpement with exclusive features such as better preview management
Simply download it thanks to Jetbrain ToolBox App
Use Android Studio
It is also possible to use Android Studio IDE with latest stable version koala version or above. You can do the following to prepare it to support KMP
For macOS devs only,kdoctor
command line interface (CLI) is available. It will help you to ensure that your computer is correctly configured for KMP development.
brew install kdoctor
+kdoctor
+
For your hand-on lab today, you can download the initial project by downloading KMP official sample for Android, iOS and Desktop & Web here: kmp.jetbrains.com
Select : ☑️ Android ☑️ iOS ☑️ Desktop ☑️ Web Download
the zip projectOpen it with Fleet
The gradle plugin of Kotlin Multiplatform ( KMP ) organize the code thanks to 2 essential notion of Gradle/Java :
A Module
is a set of classes and packages that form a complete whole with a build description file build.gradle
. Modules have been introduced to improve safety and to make the platform more modular. A Source sets
give us a powerful way to structure source code in our Gradle projects. A SourceSet represents a logical group of Kotlin source and resource files.
A shared library module linked to all project platforms. It contains the source code common to all your supported platforms.
This is the place where you will code all your cross platform composables.
On the sample, your first composable function App()
is already configured with a single button that display an image with a standard animation on click.
App.kt @Composable
+@Preview
+fun App ( ) {
+ MaterialTheme {
+ var showContent by remember { mutableStateOf ( false ) }
+ Column ( Modifier. fillMaxWidth ( ) , horizontalAlignment = Alignment. CenterHorizontally) {
+ Button ( onClick = { showContent = ! showContent } ) {
+ Text ( "Click me!" )
+ }
+ AnimatedVisibility ( showContent) {
+ val greeting = remember { Greeting ( ) . greet ( ) }
+ Column ( Modifier. fillMaxWidth ( ) , horizontalAlignment = Alignment. CenterHorizontally) {
+ Image ( painterResource ( Res. drawable. compose_multiplatform) , null )
+ Text ( "Compose: $ greeting " )
+ }
+ }
+ }
+ }
+}
+
One submodule per platform, linked to the common module sources. It gives the possibility to make specific implementations of functions per platform
When you need a specific implementation for Android and iOS of getPlatform() to return the platform name, KMP uses :
expect
keyword on the KMP shared library (commonMain) before functions indicating that we need a specific implementation of this functionactual
keywords on the KMP shared library specific modules (iosMain, androidMain) before functions to indicate the implementation.For exemple on this specific template, a getPlatformName
fuction is referenced on the common code and implemented specificly on each sourceset with the right platform name
platform.kt (SourceSet : commonMain) expect fun getPlatform ( ) : Platform
+
Platform.desktop.kt (SourceSet : desktopMain) actual fun getPlatformName ( ) : String = "Desktop"
+
Platform.android.kt (SourceSet : androidMain) actual fun getPlatformName ( ) : String = "Android"
+
Platform.ios.kt(SourceSet : iosMain) actual fun getPlatformName ( ) : String = "iOS"
+
More Information
On each platform sourceSet (androidMain
, desktopMain
, iosMain
, wasmJsMain
) , you can call native SDK function wrapped in Kotlin.
Ex: on Platform.ios.kt a UIDevice function is called :
UIDevice. currentDevice. systemName ( )
+
More information about platform specific functions in KMP here )
On this template a wrapper is used to use the root multiplatform composable App()
on each specific sourceSet Main
class :
onCreate
callback of an Activity
for AndroidA ViewController
class for iOS ... Then you can code and declare your composables on the App()
composable to code multiplatform. main.desktop.kt(SourceSet : desktopMain) fun main ( ) = application {
+ Window (
+ onCloseRequest = :: exitApplication,
+ title = "Quiz" ,
+ ) {
+ App ( )
+ }
+}
+
The Android app declaration with ressouces, manifest and activities A MainView
android composable is created from the App() composable.
main.android.kt (SourceSet : androidMain) @Composable fun MainView ( ) = App ( )
+
Then the composable is declared on the activity.
MainActivity.kt (androidApp) class MainActivity : ComponentActivity ( ) {
+ override fun onCreate ( savedInstanceState: Bundle? ) {
+ super . onCreate ( savedInstanceState)
+
+ setContent {
+ App ( )
+ }
+ }
+}
+
For iOSApp
project you can open the .xcodeproj with Xcode for completion, build specific configurations
It's the same principles, a swift MainViewController
that is created from the composable App()
main.ios.kt(SourceSet : iosMain) fun MainViewController ( ) = ComposeUIViewController { App ( ) }
+
Then on the .xcodeproj, ContentView.swift
convert the MainViewController
into a swiftUI view.
ContentView.swift (iosApp) .. .
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController ( context: Context) -> UIViewController {
+ MainViewControllerKt. MainViewController ( )
+ }
+
+ func updateUIViewController ( _ uiViewController: UIViewController, context: Context) { }
+}
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView ( )
+ . ignoresSafeArea ( . keyboard)
+ }
+}
+.. .
+
With those configuration you can now develop your composable in the commonMain
SourceSet and deploy your app for Android, iOS and Destop targets
To defines gradle configuration for deploying your development apps, you need to create a running configuration for fleet by creating a run.json
file in .fleet
folder.
.fleet/run.json {
+ "configurations" : [
+
+ {
+ "name" : "composeApp" ,
+ "type" : "gradle" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "tasks" : [ ":server:classes" ] ,
+ "initScripts" : {
+ "flmapper" : "ext.mapPath = { path -> path }" ,
+ "Build learning-kotlin-multiplatform-src" : "System.setProperty('org.gradle.java.compile-classpath-packaging', 'true')"
+ }
+ } ,
+ {
+ "name" : "server" ,
+ "type" : "jps-run" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "dependsOn" : [ "composeApp" ] ,
+ "mainClass" : "com.worldline.quiz.ApplicationKt" ,
+ "module" : "Quiz.server.main" ,
+ "options" : [ "-Dfile.encoding=UTF-8" ]
+ } ,
+ {
+ "name" : "iOS" ,
+ "type" : "xcode-app" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "allowParallelRun" : true ,
+ "buildTarget" : {
+ "project" : "iosApp" ,
+ "target" : "iosApp"
+ } ,
+ "configuration" : "Debug"
+ } ,
+ {
+ "name" : "wasmJs" ,
+ "type" : "gradle" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "tasks" : [ "wasmJsBrowserDevelopmentRun" ] ,
+ "args" : [ "-p" , "$PROJECT_DIR$/composeApp" ] ,
+ "initScripts" : {
+ "flmapper" : "ext.mapPath = { path -> path }"
+ }
+ } ,
+ {
+ "name" : "android" ,
+ "type" : "android-app" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "allowParallelRun" : true ,
+ "module" : "quiz.composeApp.main"
+ } ,
+ {
+ "name" : "Desktop" ,
+ "type" : "gradle" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "tasks" : [ "desktopRun" ] ,
+ "args" : [ "-DmainClass=com.worldline.quiz.MainKt" , "--quiet" , "-p" , "$PROJECT_DIR$/composeApp" ] ,
+ "initScripts" : {
+ "flmapper" : "ext.mapPath = { path -> path }"
+ }
+ }
+ ]
+}
+
Instead, if you want to use gradle tasks , here are some examples :
./gradlew desktopRun
+./gradlew wasmJsBrowserDevelopmentRun
+
CORS issue for Web target
For the Web App, you can bypass CORS issue if you don't have a remote server with Chrome as below:
< google chrome path> --disable-web-security --user-data-dir= /Users/xxxx/Desktop/googlechrometmp http://localhost:8080/
+
A version catalog
is a list of dependencies, represented as dependency coordinates, that a user can pick from when declaring dependencies in a build script.
gradle/libs.versions.toml
+[ versions ]
+
+
+kotlin = "2.0.20"
+agp = "8.5.0"
+compose-plugin = "1.7.0-rc01"
+
+androidx-activityCompose = "1.9.2"
+navigation = "2.8.0-alpha10"
+androidx-lifecycle = "2.8.0"
+kotlinxCoroutinesCore = "1.9.0"
+
+kotlinx-coroutines = "1.8.1"
+kotlinxDatetime = "0.6.1"
+ktorVersion = "3.0.0-rc-1"
+kstore = "0.8.0"
+logback = "1.5.8"
+
+android-compileSdk = "34"
+android-minSdk = "24"
+android-targetSdk = "34"
+
+[ libraries ]
+androidx-activity-compose = { module = "androidx.activity:activity-compose" , version.ref = "androidx-activityCompose" }
+
+androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle" , name = "lifecycle-viewmodel" , version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle" , name = "lifecycle-runtime-compose" , version.ref = "androidx-lifecycle" }
+
+kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx" , name = "kotlinx-coroutines-swing" , version.ref = "kotlinx-coroutines" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" , version.ref = "kotlinxCoroutinesCore" }
+
+
+
+kotlin-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose" , version.ref = "navigation" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime" , version.ref = "kotlinxDatetime" }
+
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json" , version.ref = "ktorVersion" }
+ktor-client-core = { module = "io.ktor:ktor-client-core" , version.ref = "ktorVersion" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation" , version.ref = "ktorVersion" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp" , version.ref = "ktorVersion" }
+ktor-client-apache = { module = "io.ktor:ktor-client-apache" , version.ref = "ktorVersion" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin" , version.ref = "ktorVersion" }
+
+
+
+kstore = { module = "io.github.xxfast:kstore" , version.ref = "kstore" }
+kstore-file = { module = "io.github.xxfast:kstore-file" , version.ref = "kstore" }
+kstore-storage = { module = "io.github.xxfast:kstore-storage" , version.ref = "kstore" }
+
+
+
+
+
+ktor-server-core = { module = "io.ktor:ktor-server-core-jvm" , version.ref = "ktorVersion" }
+ktor-server-cio = { module = "io.ktor:ktor-server-cio" , version.ref = "ktorVersion" }
+ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation" , version.ref = "ktorVersion" }
+ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml" , version.ref = "ktorVersion" }
+ktor-server-cors = { module = "io.ktor:ktor-server-cors" , version.ref = "ktorVersion" }
+logback = { module = "ch.qos.logback:logback-classic" , version.ref = "logback" }
+
+[ plugins ]
+androidApplication = { id = "com.android.application" , version.ref = "agp" }
+androidLibrary = { id = "com.android.library" , version.ref = "agp" }
+jetbrainsCompose = { id = "org.jetbrains.compose" , version.ref = "compose-plugin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose" , version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform" , version.ref = "kotlin" }
+kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization" , version.ref = "kotlin" }
+kotlinJvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlin" }
+ktor = { id = "io.ktor.plugin" , version.ref = "ktorVersion" }
+
+
A logger is provided by [Ktor client library
] (https://ktor.io/docs/logging.html) for basic logs.
✅ If everything is fine, go to the next chapter →
`,66)]))}const g=s(k,[["render",d],["__file","index.html.vue"]]),y=JSON.parse('{"path":"/configure/","title":"Configure KMP","lang":"en-US","frontmatter":{"description":"Configure KMP Fleet IDE is the dedicated IDE to consider for KMP developpement with exclusive features such as better preview management Simply download it thanks to Jetbrain To...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/configure/"}],["meta",{"property":"og:title","content":"Configure KMP"}],["meta",{"property":"og:description","content":"Configure KMP Fleet IDE is the dedicated IDE to consider for KMP developpement with exclusive features such as better preview management Simply download it thanks to Jetbrain To..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T13:49:37.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T13:49:37.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"Configure KMP\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-04T13:49:37.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"🧪 Download the initial project","slug":"🧪-download-the-initial-project","link":"#🧪-download-the-initial-project","children":[]},{"level":2,"title":"📚 A Guided tour of the sample project","slug":"📚-a-guided-tour-of-the-sample-project","link":"#📚-a-guided-tour-of-the-sample-project","children":[]},{"level":2,"title":"🧪 Deploy your apps","slug":"🧪-deploy-your-apps","link":"#🧪-deploy-your-apps","children":[]},{"level":2,"title":"Version Catalog","slug":"version-catalog","link":"#version-catalog","children":[]},{"level":2,"title":"Basic logging","slug":"basic-logging","link":"#basic-logging","children":[]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1728049777000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":18,"url":"https://github.com/Ibrahim Gharbi"},{"name":"A187839","email":"ibrahim.gharbi@worldline.com","commits":6,"url":"https://github.com/A187839"}]},"filePathRelative":"configure/README.md","autoDesc":true}');export{g as comp,y as data};
diff --git a/assets/index.html-dJGFTDYi.js b/assets/index.html-dJGFTDYi.js
new file mode 100644
index 0000000..fd8b571
--- /dev/null
+++ b/assets/index.html-dJGFTDYi.js
@@ -0,0 +1,263 @@
+import{_ as s,o as a,c as p,a as e}from"./app-DaulDPFL.js";const t="/learning-kotlin-multiplatform/assets/welcomescreen-fw1_MuGU.png",o="/learning-kotlin-multiplatform/assets/scorescreen-CgD5TFZC.png",i="/learning-kotlin-multiplatform/assets/uml-CgIVGHo2.png",l="/learning-kotlin-multiplatform/assets/quizscreen-BAbAaB3e.png",c={};function u(r,n){return a(),p("div",null,n[0]||(n[0]=[e(`Compose Multiplatform simplifies and accelerates UI development for Desktop and Web applications, and allows extensive UI code sharing between Android, iOS, Desktop and Web. It's a modern toolkit for building native UI. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs. It is based on Android Jetpack Compose declarative UI approach ( which is similar also to SwiftUI for iOS )
Composables are UI components that can be simply declared with code as functions, properties (such as text color, fonts...) as function parameters and subviews are declared on function declaration.
An @Composable
annotation come always before the composable function. Properties of size, behaviors of components can be set thanks to Modifiers
. It permit to decorate and augent composables You can align components with containers composables such as Column
(Vertically), Box
, Row
(Horizontally) Also you can preview composables with the annotation @Preview
before the composable annotation. Example: 2 texts vertically aligned that fit all the width of the screen.
@Composable
+fun App ( ) {
+ MaterialTheme {
+ Column ( Modifier. fillMaxWidth ( ) ) {
+ Text ( "My Text1" , color = Color. Blue)
+ Text ( text = "My Text2" )
+ }
+ }
+}
+
You can now create your first view. For the Quiz we need a welcome screen displaying a Card centered with a button inside to start the quiz It is simply compose of the following composables :
a Card rounded shape container
a Text
a Button
Create a new composable WelcomeScreen.kt
on commonMain module
Make sure that the App() composable is using it has below
@Composable
+fun App ( ) {
+ MaterialTheme {
+ welcomeScreen ( )
+ }
+}
+
Run you first view on all platforms , it should work. WelcomeScreen.kt (SourseSet : commonMain) package com. worldline. quiz
+
+import androidx. compose. foundation. layout. Box
+import androidx. compose. foundation. layout. Column
+import androidx. compose. foundation. layout. fillMaxHeight
+import androidx. compose. foundation. layout. fillMaxWidth
+import androidx. compose. foundation. layout. padding
+import androidx. compose. foundation. shape. RoundedCornerShape
+import androidx. compose. material. Button
+import androidx. compose. material. Card
+import androidx. compose. material. Text
+import androidx. compose. runtime. Composable
+import androidx. compose. ui. Alignment
+import androidx. compose. ui. Modifier
+import androidx. compose. ui. unit. dp
+import androidx. compose. ui. unit. sp
+
+@Composable
+fun welcomeScreen ( ) {
+
+ Box (
+ contentAlignment = Alignment. Center,
+ modifier = Modifier. fillMaxWidth ( ) . fillMaxHeight ( )
+ ) {
+ Card (
+ shape = RoundedCornerShape ( 8 . dp) ,
+ modifier = Modifier. padding ( 10 . dp) ,
+ ) {
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+
+
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+ Text (
+ text = "Quiz" ,
+ fontSize = 30 . sp,
+ modifier = Modifier. padding ( all = 10 . dp)
+ )
+ Text (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ text = "A simple Quiz to discovers KMP and compose." ,
+ )
+ Button (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ onClick = { }
+
+ ) {
+ Text ( "Start the Quiz" )
+ }
+ }
+ }
+ }
+ }
+}
+
The second view will be quite similar but able de show final scores
Create a new composable ScoreScreen.kt
on commonMain module Make sure that the App() composable is using it has below The composable will have a String
value as parameter @Composable
+fun App ( ) {
+ MaterialTheme {
+ scoreScreen ( "10/20" )
+ }
+}
+
Run you first view on all platforms , it should work. ScoreScreen.kt (SourseSet : commonMain) package com. worldline. quiz
+
+import androidx. compose. foundation. layout. Box
+import androidx. compose. foundation. layout. Column
+import androidx. compose. foundation. layout. fillMaxHeight
+import androidx. compose. foundation. layout. fillMaxWidth
+import androidx. compose. foundation. layout. padding
+import androidx. compose. foundation. shape. RoundedCornerShape
+import androidx. compose. material. Button
+import androidx. compose. material. Card
+import androidx. compose. material. Icon
+import androidx. compose. material. Text
+import androidx. compose. material. icons. Icons
+import androidx. compose. material. icons. filled. Refresh
+import androidx. compose. runtime. Composable
+import androidx. compose. ui. Alignment
+import androidx. compose. ui. Modifier
+import androidx. compose. ui. graphics. Color
+import androidx. compose. ui. unit. dp
+import androidx. compose. ui. unit. sp
+
+@Composable
+fun scoreScreen ( score: String) {
+ Box (
+ contentAlignment = Alignment. Center,
+ modifier = Modifier. fillMaxWidth ( ) . fillMaxHeight ( )
+ ) {
+ Card (
+ shape = RoundedCornerShape ( 8 . dp) ,
+ modifier = Modifier. padding ( 10 . dp) ,
+ backgroundColor = Color. Green
+ ) {
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+ Text (
+ fontSize = 15 . sp,
+ text = "score" ,
+ )
+ Text (
+ fontSize = 30 . sp,
+ text = score,
+ )
+ Button (
+ modifier = Modifier. padding ( all = 20 . dp) ,
+ onClick = {
+ }
+ ) {
+ Icon ( Icons. Filled. Refresh, contentDescription = "Localized description" )
+ Text ( text = "Retake the Quiz" )
+ }
+ }
+ }
+ }
+ }
+}
+
We can create classes on the package network.data
Answer.kt (commonMain) data class Answer ( val id: Int, val label: String )
+
Question.kt (commonMain) data class Question ( val id: Int, val label: String, val correctAnswerId: Int, val answers: List< Answer> )
+
Quiz.kt (commonMain) data class Quiz ( var questions: List< Question> )
+
Now we can make a composable with interactions.
The screen is composed of :
The question label in a Card
Single choice answer component with RadioButton
A Button
to submit the answer A LinearProgressIndicator
indicating the quiz progress After creating the UI view, we can pass to this composable the list of questions. When the App
composable will create questionScreen()
composable we will generate mock questions data for now to generate the list of questions.
All views of question will be one unique composable that updates with the correct question/answers data each time we are clicking on the next
button.
We use MutableState
value for that. It permit to keep data value and recompose the view when the data is changed. It's exactly what we need for our quiz page :
Keep the value of the question position on the list Keep the value of the answer selected by the user each time he switch between RadioButtons Keep the score to get the final one at the end of the list. Here is an example of MutableState
value declaration
var questionProgress by remember { mutableStateOf ( 0 ) }
+ .. .
+
You can declare the 2 other MutableState values and after use it on your composable ensuring that on the button click questionProgress
is incrementing so the question and his answers can change on the view.
QuestionScreen.kt (SourceSet : commonMain) package com. worldline. quiz
+
+import androidx. compose. foundation. layout. Arrangement
+import androidx. compose. foundation. layout. Column
+import androidx. compose. foundation. layout. Row
+import androidx. compose. foundation. layout. fillMaxHeight
+import androidx. compose. foundation. layout. fillMaxWidth
+import androidx. compose. foundation. layout. height
+import androidx. compose. foundation. layout. padding
+import androidx. compose. foundation. selection. selectableGroup
+import androidx. compose. foundation. shape. RoundedCornerShape
+import androidx. compose. material. Button
+import androidx. compose. material. Card
+import androidx. compose. material. Icon
+import androidx. compose. material. LinearProgressIndicator
+import androidx. compose. material. RadioButton
+import androidx. compose. material. Text
+import androidx. compose. material. icons. Icons
+import androidx. compose. material. icons. automirrored. filled. ArrowForward
+import androidx. compose. material. icons. filled. Done
+import androidx. compose. runtime. Composable
+import androidx. compose. runtime. getValue
+import androidx. compose. runtime. mutableStateOf
+import androidx. compose. runtime. remember
+import androidx. compose. runtime. setValue
+import androidx. compose. ui. Alignment
+import androidx. compose. ui. Modifier
+import androidx. compose. ui. graphics. vector. ImageVector
+import androidx. compose. ui. text. style. TextAlign
+import androidx. compose. ui. unit. dp
+import androidx. compose. ui. unit. sp
+import network. data. Question
+
+@Composable
+fun questionScreen ( questions: List< Question> ) {
+
+ var questionProgress by remember { mutableStateOf ( 0 ) }
+ var selectedAnswer by remember { mutableStateOf ( 1 ) }
+ var score by remember { mutableStateOf ( 0 ) }
+
+ Column (
+ modifier = Modifier. fillMaxWidth ( ) . fillMaxHeight ( ) ,
+ verticalArrangement = Arrangement. Center,
+ horizontalAlignment = Alignment. CenterHorizontally
+ ) {
+ Card (
+ shape = RoundedCornerShape ( 5 . dp) ,
+ modifier = Modifier. padding ( 60 . dp)
+ ) {
+ Column (
+ horizontalAlignment = Alignment. CenterHorizontally,
+ modifier = Modifier. padding ( horizontal = 10 . dp)
+ ) {
+ Text (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ text = questions[ questionProgress] . label,
+ fontSize = 25 . sp,
+ textAlign = TextAlign. Center
+ )
+ }
+ }
+ Column ( modifier = Modifier. selectableGroup ( ) ) {
+ questions[ questionProgress] . answers. forEach { answer ->
+ Row (
+ modifier = Modifier. padding ( horizontal = 16 . dp) ,
+ verticalAlignment = Alignment. CenterVertically
+ ) {
+ RadioButton (
+ modifier = Modifier. padding ( end = 16 . dp) ,
+ selected = ( selectedAnswer == answer. id) ,
+ onClick = { selectedAnswer = answer. id } ,
+ )
+ Text ( text = answer. label)
+ }
+ }
+ }
+ Column ( modifier = Modifier. fillMaxHeight ( ) , horizontalAlignment = Alignment. CenterHorizontally, verticalArrangement = Arrangement. Bottom) {
+ Button (
+ modifier = Modifier. padding ( bottom = 20 . dp) ,
+ onClick = {
+ if ( selectedAnswer == questions[ questionProgress] . correctAnswerId) {
+ score++
+ }
+ if ( questionProgress < questions. size - 1 ) {
+ questionProgress++
+ selectedAnswer = 1
+ } else {
+
+ }
+ }
+ ) {
+ if ( questionProgress < questions. size - 1 ) nextOrDoneButton ( Icons. AutoMirrored. Filled. ArrowForward, "Next" )
+ else nextOrDoneButton ( Icons. Filled. Done, "Done" )
+ }
+ LinearProgressIndicator ( modifier = Modifier. fillMaxWidth ( ) . height ( 20 . dp) , progress = questionProgress. div ( questions. size. toFloat ( ) ) . plus ( 1 . div ( questions. size. toFloat ( ) ) ) )
+ }
+ }
+}
+
+@Composable
+fun nextOrDoneButton ( iv: ImageVector, label: String) {
+ Icon (
+ iv,
+ contentDescription = "Localized description" ,
+ Modifier. padding ( end = 15 . dp)
+ )
+ Text ( label)
+}
+
App.kt (SourceSet : commonMain) @Composable
+fun App ( ) {
+ MaterialTheme {
+ val questions = listOf (
+ Question (
+ 1 ,
+ "Android is a great platform ?" ,
+ 1 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ ) ,
+ Question (
+ 1 ,
+ "Android is a bad platform ?" ,
+ 2 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ )
+ )
+ questionScreen ( questions)
+ }
+}
+
Your Quiz have now all his composable screens made. Let's connect it to the Internet
✅ If everything is fine, go to the next chapter →
Sources
The full solution for this section is availabe here
`,55)]))}const k=s(c,[["render",u],["__file","index.html.vue"]]),m=JSON.parse('{"path":"/ui/","title":"User interface","lang":"en-US","frontmatter":{"description":"User interface 📚 Reminder Compose Multiplatform Compose Multiplatform simplifies and accelerates UI development for Desktop and Web applications, and allows extensive UI code s...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/ui/"}],["meta",{"property":"og:title","content":"User interface"}],["meta",{"property":"og:description","content":"User interface 📚 Reminder Compose Multiplatform Compose Multiplatform simplifies and accelerates UI development for Desktop and Web applications, and allows extensive UI code s..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T13:49:37.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T13:49:37.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"User interface\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-04T13:49:37.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"📚 Reminder","slug":"📚-reminder","link":"#📚-reminder","children":[{"level":3,"title":"Compose Multiplatform","slug":"compose-multiplatform","link":"#compose-multiplatform","children":[]},{"level":3,"title":"How to create composables ?","slug":"how-to-create-composables","link":"#how-to-create-composables","children":[]}]},{"level":2,"title":"Create composable for the Quiz","slug":"create-composable-for-the-quiz","link":"#create-composable-for-the-quiz","children":[{"level":3,"title":"🧪 WelcomeScreen","slug":"🧪-welcomescreen","link":"#🧪-welcomescreen","children":[]},{"level":3,"title":"🎯 Solutions","slug":"🎯-solutions","link":"#🎯-solutions","children":[]},{"level":3,"title":"🧪 ScoreScreen","slug":"🧪-scorescreen","link":"#🧪-scorescreen","children":[]},{"level":3,"title":"🎯 Solutions","slug":"🎯-solutions-1","link":"#🎯-solutions-1","children":[]},{"level":3,"title":"🧪 QuestionScreen","slug":"🧪-questionscreen","link":"#🧪-questionscreen","children":[]},{"level":3,"title":"🎯 Solutions","slug":"🎯-solutions-2","link":"#🎯-solutions-2","children":[]}]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1728049777000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":15,"url":"https://github.com/Ibrahim Gharbi"},{"name":"A187839","email":"ibrahim.gharbi@worldline.com","commits":6,"url":"https://github.com/A187839"}]},"filePathRelative":"ui/README.md","autoDesc":true}');export{k as comp,m as data};
diff --git a/assets/index.html-pIQXzd7o.js b/assets/index.html-pIQXzd7o.js
new file mode 100644
index 0000000..c081c72
--- /dev/null
+++ b/assets/index.html-pIQXzd7o.js
@@ -0,0 +1,277 @@
+import{_ as s,o as a,c as t,a as e}from"./app-DaulDPFL.js";const p="/learning-kotlin-multiplatform/assets/server_tree-Gse56Whh.png",i={};function l(o,n){return a(),t("div",null,n[0]||(n[0]=[e(`Let's connect our Quiz app to internet.
For now, we will request a simple plain text json file hosted on this repo that will simulate a REST API call to be able to use our Ktor client.
The request & answers details are specified below :
Request POST
+content-type: text/plain
+url: https://github.com/worldline/learning-kotlin-multiplatform/raw/main/quiz.json
+
Answer code:200
+body:
+{
+ "questions" : [
+ {
+ "id" :1,
+ "label" : "You can create an emulator to simulate the configuration of a particular type of Android device using a tool like" ,
+ "correct_answer_id" :3,
+ "answers" :[
+ { "id" :1, "label" : "Theme Editor" } ,
+ { "id" :2, "label" : "Android SDK Manager" } ,
+ { "id" :3, "label" : "AVD Manager" } ,
+ { "id" :4, "label" : "Virtual Editor" }
+ ]
+ } ,
+ {
+ "id" :2,
+ "label" : "What parameter specifies the Android API level that Gradle should use to compile your app?" ,
+ "correct_answer_id" :2,
+ "answers" :[
+ { "id" :1, "label" : "minSdkVersion" } ,
+ { "id" :2, "label" : "compileSdkVersion" } ,
+ { "id" :3, "label" : "targetSdkVersion" } ,
+ { "id" :4, "label" : "testSdkVersion" }
+ ]
+ } ,
+ ]
+}
+
To not overcomplexify the app, let's assume that :
the QuizAPI provided by Ktor (cf below) is our data source the repository will use a state flow that emit the API answer once at application startup Ktor includes a multiplatform asynchronous HTTP client, which allows you to make requests and handle responses, extend its functionality with plugins, such as authentication and JSON deserialization.
Shared sources need it to use ktor library on your code
build.gradle.kts (composeApp) plugins {
+.. .
+ alias ( libs. plugins. kotlinSerialization)
+}
+
+.. .
+ sourceSets {
+ val desktopMain by getting
+ commonMain. dependencies {
+ .. .
+ implementation ( libs. kotlinx. datetime)
+ implementation ( libs. ktor. client. core)
+ implementation ( libs. ktor. client. content. negotiation)
+ implementation ( libs. ktor. serialization. kotlinx. json)
+
+ }
+ androidMain. dependencies {
+ .. .
+ implementation ( libs. ktor. client. okhttp)
+ }
+ desktopMain. dependencies {
+ .. .
+ implementation ( libs. ktor. client. apache)
+
+ }
+ iosMain. dependencies {
+ implementation ( libs. ktor. client. darwin)
+ }
+
+ }
+.. .
+
+
You need to enable internet on Android otherwise you will not be able to use ktor client
AndroidManifest.xml( androidMain) < uses-permission android: name= " android.permission.INTERNET" />
+ < uses-permission android: name= " android.permission.ACCESS_NETWORK_STATE" />
+
QuizApiDataSource.kt (SourceSet : commonMain) import com. worldline. quiz. data. dataclass. Quiz
+
+val globalHttpClient = HttpClient {
+ engine {
+
+ }
+
+ install ( ContentNegotiation) {
+ json (
+ contentType = ContentType. Text. Plain,
+ json = Json {
+ ignoreUnknownKeys = true
+ useAlternativeNames = false
+ } )
+ }
+}
+
+class QuizApiDatasource {
+ private val httpClient = globalHttpClient
+ suspend fun getAllQuestions ( ) : Quiz {
+ return httpClient. get ( "https://raw.githubusercontent.com/worldline/learning-kotlin-multiplatform/main/quiz.json" ) . body ( )
+ }
+}
+
+
Ktor need it to transform the json string into your dataclasses
Answer.kt (module : commonMain) @kotlinx . serialization. Serializable
+data class Answer ( val id: Int, val label: String )
+
Question.kt (SourceSet : commonMain) import kotlinx. serialization. SerialInfo
+import kotlinx. serialization. SerialName
+
+@kotlinx . serialization. Serializable
+data class Question ( val id: Int, val label: String, @SerialName ( "correct_answer_id" ) val correctAnswerId: Int, val answers: List< Answer> )
+
Quiz.kt (SourceSet : commonMain) @kotlinx . serialization. Serializable
+data class Quiz ( var questions: List< Question> )
+
QuizRepository.kt (module : commonMain) class QuizRepository {
+
+ private val mockDataSource = MockDataSource ( )
+ private val quizApiDatasource = QuizApiDatasource ( )
+
+ private suspend fun fetchQuiz ( ) : List< Question> = quizApiDatasource. getAllQuestions ( ) . questions
+
+ suspend fun updateQuiz ( ) : List< Question> {
+ try {
+ return fetchQuiz ( )
+ } catch ( e: Exception) {
+ e. printStackTrace ( )
+ return mockDataSource. generateDummyQuestionsList ( )
+ }
+ }
+}
+
Sources
The full sources can be retrieved here
Warning
You can create the server module from IntelliJ community or ultimate thanks to a template.
The module tree is as follow
build.gradle.kts plugins {
+ alias ( libs. plugins. kotlinJvm)
+ alias ( libs. plugins. ktor)
+ alias ( libs. plugins. kotlinSerialization)
+ application
+}
+
+group = "com.worldline.quiz"
+version = "1.0.0"
+
+application {
+ mainClass. set ( "com.worldline.quiz.ApplicationKt" )
+ applicationDefaultJvmArgs = listOf ( "-Dio.ktor.development= \${ extra[ "io.ktor.development" ] ?: "false" } " )
+}
+
+dependencies {
+ implementation ( libs. logback)
+ implementation ( libs. ktor. server. core)
+ implementation ( libs. ktor. server. cio)
+ implementation ( libs. ktor. serialization. kotlinx. json)
+ implementation ( libs. ktor. server. content. negotiation)
+ implementation ( libs. ktor. server. cors)
+ implementation ( libs. ktor. server. config. yaml)
+}
+
+ktor {
+ fatJar {
+ archiveFileName. set ( "fat.jar" )
+ }
+ docker {
+ externalRegistry. set (
+ io. ktor. plugin. features. DockerImageRegistry. dockerHub (
+ appName = provider { "ktor-quiz" } ,
+ username = providers. environmentVariable ( "KTOR_IMAGE_REGISTRY_USERNAME" ) ,
+ password = providers. environmentVariable ( "KTOR_IMAGE_REGISTRY_PASSWORD" )
+ )
+ )
+ }
+}
+
Application.kt ffun main ( args: Array< String> ) {
+ io. ktor. server. cio. EngineMain. main ( args)
+}
+
+
+fun Application. module ( ) {
+
+ install ( CORS) {
+ allowMethod ( HttpMethod. Options)
+ allowMethod ( HttpMethod. Post)
+ allowMethod ( HttpMethod. Get)
+ allowHeader ( HttpHeaders. AccessControlAllowOrigin)
+ allowHeader ( HttpHeaders. ContentType)
+ anyHost ( )
+ }
+
+ install ( ContentNegotiation) {
+ json ( )
+ }
+ configureRouting ( )
+}
+
Routing.kt fun Application. configureRouting ( ) {
+
+ routing {
+ get ( "/quiz" ) {
+ call. respond ( generateQuiz ( ) )
+ }
+ staticResources ( "/" , "static" )
+ }
+}
+
+fun generateQuiz ( ) : Quiz {
+ val quizQuestions = mutableListOf< Question> ( )
+
+ val questions = listOf (
+ "What is the primary goal of Kotlin Multiplatform?" ,
+ "How does Kotlin Multiplatform facilitate code sharing between platforms?" ,
+ "Which platforms does Kotlin Multiplatform support?" ,
+ "What is a common use case for Kotlin Multiplatform?" ,
+ "Which naming of KMP is deprecated?" ,
+ "How does Kotlin Multiplatform handle platform-specific implementations?" ,
+ "At which Google I/O, Google announced first-class support for Kotlin on Android?" ,
+ "What is the name of the Kotlin mascot?" ,
+ "The international yearly Kotlin conference is called..." ,
+ "Where will be located the next international yearly Kotlin conference?"
+ )
+
+ val answers = listOf (
+ listOf (
+ "To share code between multiple platforms" ,
+ "To exclusively compile code to JavaScript" ,
+ "To build only Android applications" ,
+ "To create iOS-only applications"
+ ) ,
+ listOf (
+ "By sharing business logic and adapting UI" ,
+ "By writing separate code for each platform" ,
+ "By using only Java libraries" ,
+ "By using code translation tools"
+ ) ,
+ listOf (
+ "Android, iOS, desktop and web" ,
+ "Only Android" ,
+ "Only iOS" ,
+ "Only web applications"
+ ) ,
+ listOf (
+ "Developing a cross-platform app" ,
+ "Building a desktop-only application" ,
+ "Creating a server-side application" ,
+ "Writing a standalone mobile app"
+ ) ,
+ listOf (
+ "Kotlin Multiplatform Mobile (KMM)" ,
+ "Hadi Multiplatform" ,
+ "Jetpack multiplatform" ,
+ "Kodee multiplatform"
+ ) ,
+ listOf (
+ "Through expect and actual declarations" ,
+ "By automatically translating code" ,
+ "By restricting to a single platform" ,
+ "By excluding platform-specific features"
+ ) ,
+ listOf (
+ "2017" ,
+ "2016" ,
+ "2014" ,
+ "2020"
+ ) ,
+ listOf (
+ "Kodee" ,
+ "Hadee" ,
+ "Kotlinee" ,
+ "Kotee"
+ ) ,
+ listOf (
+ "KotlinConf" ,
+ "KodeeConf" ,
+ "KConf" ,
+ "KotlinKonf"
+ ) ,
+ listOf (
+ "Copenhagen, Denmark" ,
+ "Amsterdam, Netherlands" ,
+ "Tokyo, Japan" ,
+ "Lille, France"
+ )
+ )
+
+ for ( i in questions. indices) {
+ val shuffledAnswers = answers[ i] . shuffled ( Random. Default)
+ val correctAnswerId = shuffledAnswers. indexOfFirst { it == answers[ i] [ 0 ] } + 1
+ val question =
+ Question ( i + 1L , questions[ i] , correctAnswerId. toLong ( ) , shuffledAnswers. mapIndexed { index, answer ->
+ Answer ( index + 1L , answer)
+ } )
+ quizQuestions. add ( question)
+ }
+
+ return Quiz ( quizQuestions)
+}
+
+
Other libs
If you want well-known retrofit style lib, you can use KtorFit to separate endpoint declaration from httpclient configuration
Also for better image loading from the internet with cache, you can use the following third-party Compose Multiplatform libraries
An that's it, you quiz have now a remote list of questions.
✅ If everything is fine, go to the next chapter →
`,42)]))}const u=s(i,[["render",l],["__file","index.html.vue"]]),r=JSON.parse(`{"path":"/network/","title":"Connectivity","lang":"en-US","frontmatter":{"description":"Connectivity Let's connect our Quiz app to internet. Connect my App For now, we will request a simple plain text json file hosted on this repo that will simulate a REST API call...","head":[["meta",{"property":"og:url","content":"https://worldline.github.io/learning-kotlin-multiplatform/learning-kotlin-multiplatform/network/"}],["meta",{"property":"og:title","content":"Connectivity"}],["meta",{"property":"og:description","content":"Connectivity Let's connect our Quiz app to internet. Connect my App For now, we will request a simple plain text json file hosted on this repo that will simulate a REST API call..."}],["meta",{"property":"og:type","content":"article"}],["meta",{"property":"og:locale","content":"en-US"}],["meta",{"property":"og:updated_time","content":"2024-10-04T20:55:50.000Z"}],["meta",{"property":"article:modified_time","content":"2024-10-04T20:55:50.000Z"}],["script",{"type":"application/ld+json"},"{\\"@context\\":\\"https://schema.org\\",\\"@type\\":\\"Article\\",\\"headline\\":\\"Connectivity\\",\\"image\\":[\\"\\"],\\"dateModified\\":\\"2024-10-04T20:55:50.000Z\\",\\"author\\":[]}"]]},"headers":[{"level":2,"title":"Connect my App","slug":"connect-my-app","link":"#connect-my-app","children":[{"level":3,"title":"🧪 Ktor as a multiplatform HTTP client","slug":"🧪-ktor-as-a-multiplatform-http-client","link":"#🧪-ktor-as-a-multiplatform-http-client","children":[]},{"level":3,"title":"🎯 Solutions","slug":"🎯-solutions","link":"#🎯-solutions","children":[]}]},{"level":2,"title":"Create a server","slug":"create-a-server","link":"#create-a-server","children":[{"level":3,"title":"🧪 Create a Ktor server module inside your actual project","slug":"🧪-create-a-ktor-server-module-inside-your-actual-project","link":"#🧪-create-a-ktor-server-module-inside-your-actual-project","children":[]},{"level":3,"title":"🎯 Solutions","slug":"🎯-solutions-1","link":"#🎯-solutions-1","children":[]}]},{"level":2,"title":"📖 Further reading","slug":"📖-further-reading","link":"#📖-further-reading","children":[]}],"git":{"updatedTime":1728075350000,"contributors":[{"name":"Ibrahim Gharbi","email":"brah.gharbi@gmail.com","commits":19,"url":"https://github.com/Ibrahim Gharbi"},{"name":"A187839","email":"ibrahim.gharbi@worldline.com","commits":9,"url":"https://github.com/A187839"}]},"filePathRelative":"network/README.md","autoDesc":true}`);export{u as comp,r as data};
diff --git a/assets/kmp_sample_src-CoII6UwA.png b/assets/kmp_sample_src-CoII6UwA.png
new file mode 100644
index 0000000..80c4b58
Binary files /dev/null and b/assets/kmp_sample_src-CoII6UwA.png differ
diff --git a/assets/logo_worldline-t5KadDQv.png b/assets/logo_worldline-t5KadDQv.png
new file mode 100644
index 0000000..6981642
Binary files /dev/null and b/assets/logo_worldline-t5KadDQv.png differ
diff --git a/assets/overview2-gDQlEEdM.png b/assets/overview2-gDQlEEdM.png
new file mode 100644
index 0000000..e81e317
Binary files /dev/null and b/assets/overview2-gDQlEEdM.png differ
diff --git a/assets/plugins_install-BKgRePQf.png b/assets/plugins_install-BKgRePQf.png
new file mode 100644
index 0000000..d49a89c
Binary files /dev/null and b/assets/plugins_install-BKgRePQf.png differ
diff --git a/assets/project_struct-DMA9UCBx.png b/assets/project_struct-DMA9UCBx.png
new file mode 100644
index 0000000..cef216f
Binary files /dev/null and b/assets/project_struct-DMA9UCBx.png differ
diff --git a/assets/quizscreen-BAbAaB3e.png b/assets/quizscreen-BAbAaB3e.png
new file mode 100644
index 0000000..f559f25
Binary files /dev/null and b/assets/quizscreen-BAbAaB3e.png differ
diff --git a/assets/routes-BuqzzffU.png b/assets/routes-BuqzzffU.png
new file mode 100644
index 0000000..50f4116
Binary files /dev/null and b/assets/routes-BuqzzffU.png differ
diff --git a/assets/run-BxGtZnDA.png b/assets/run-BxGtZnDA.png
new file mode 100644
index 0000000..ddbeb0d
Binary files /dev/null and b/assets/run-BxGtZnDA.png differ
diff --git a/assets/scorescreen-CgD5TFZC.png b/assets/scorescreen-CgD5TFZC.png
new file mode 100644
index 0000000..756abeb
Binary files /dev/null and b/assets/scorescreen-CgD5TFZC.png differ
diff --git a/assets/screens-p5CGXVR7.png b/assets/screens-p5CGXVR7.png
new file mode 100644
index 0000000..5409f06
Binary files /dev/null and b/assets/screens-p5CGXVR7.png differ
diff --git a/assets/server_tree-Gse56Whh.png b/assets/server_tree-Gse56Whh.png
new file mode 100644
index 0000000..1f4c4ae
Binary files /dev/null and b/assets/server_tree-Gse56Whh.png differ
diff --git a/assets/setupDevtools-7MC2TMWH-CzOqVNI-.js b/assets/setupDevtools-7MC2TMWH-CzOqVNI-.js
new file mode 100644
index 0000000..63c4ab2
--- /dev/null
+++ b/assets/setupDevtools-7MC2TMWH-CzOqVNI-.js
@@ -0,0 +1 @@
+import{s as T,w as E}from"./app-DaulDPFL.js";var l="org.vuejs.vuepress",v="VuePress",I=v,r=l,N=v,i="client-data",a="Client Data",g=(p,n)=>{T({app:p,id:l,label:v,packageName:"@vuepress/client",homepage:"https://vuepress.vuejs.org",logo:"https://vuepress.vuejs.org/images/hero.png",componentStateTypes:[I]},t=>{const c=Object.entries(n),u=Object.keys(n),d=Object.values(n);t.on.inspectComponent(e=>{e.instanceData.state.push(...c.map(([s,o])=>({type:I,editable:!1,key:s,value:o.value})))}),t.addInspector({id:r,label:N,icon:"article"}),t.on.getInspectorTree(e=>{e.inspectorId===r&&(e.rootNodes=[{id:i,label:a,children:u.map(s=>({id:s,label:s}))}])}),t.on.getInspectorState(e=>{e.inspectorId===r&&(e.nodeId===i&&(e.state={[a]:c.map(([s,o])=>({key:s,value:o.value}))}),u.includes(e.nodeId)&&(e.state={[a]:[{key:e.nodeId,value:n[e.nodeId].value}]}))}),E(d,()=>{t.notifyComponentUpdate(),t.sendInspectorState(r)})})};export{g as setupDevtools};
diff --git a/assets/style-CmjR2_g8.css b/assets/style-CmjR2_g8.css
new file mode 100644
index 0000000..e39dec9
--- /dev/null
+++ b/assets/style-CmjR2_g8.css
@@ -0,0 +1 @@
+.vp-back-to-top-button{position:fixed!important;inset-inline-end:1rem;bottom:4rem;z-index:100;width:48px;height:48px;padding:12px;border-width:0;border-radius:50%;background:var(--back-to-top-c-bg);color:var(--back-to-top-c-accent-bg);box-shadow:2px 2px 10px 4px var(--back-to-top-c-shadow);cursor:pointer}@media (max-width: 959px){.vp-back-to-top-button{transform:scale(.8);transform-origin:100% 100%}}@media print{.vp-back-to-top-button{display:none}}.vp-back-to-top-button:hover{color:var(--back-to-top-c-accent-hover)}.vp-back-to-top-button .back-to-top-icon{overflow:hidden;width:24px;height:24px;margin:0 auto;background:var(--back-to-top-c-icon);-webkit-mask-image:var(--back-to-top-icon);mask-image:var(--back-to-top-icon);-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:cover;mask-size:cover}.vp-scroll-progress{position:absolute;right:-2px;bottom:-2px;width:52px;height:52px}.vp-scroll-progress svg{width:100%;height:100%}.vp-scroll-progress circle{opacity:.9;transform:rotate(-90deg);transform-origin:50% 50%}.back-to-top-enter-active,.back-to-top-leave-active{transition:opacity .3s}.back-to-top-enter-from,.back-to-top-leave-to{opacity:0}:root{--back-to-top-z-index: 5;--back-to-top-icon: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2048%2048'%3e%3cpath%20fill='none'%20stroke='currentColor'%20stroke-linecap='round'%20stroke-linejoin='round'%20stroke-width='4'%20d='M24.008%2014.1V42M12%2026l12-12l12%2012M12%206h24'%20/%3e%3c/svg%3e");--back-to-top-c-bg: var(--vp-c-bg);--back-to-top-c-accent-bg: var(--vp-c-accent-bg);--back-to-top-c-accent-hover: var(--vp-c-accent-hover);--back-to-top-c-shadow: var(--vp-c-shadow);--back-to-top-c-icon: currentcolor}.vp-copy-code-button{position:absolute;top:.5em;right:.5em;z-index:5;width:2.5rem;height:2.5rem;padding:0;border-width:0;border-radius:.5rem;background:#0000;outline:none;opacity:0;cursor:pointer;transition:opacity .4s}@media print{.vp-copy-code-button{display:none}}.vp-copy-code-button:before{content:"";display:inline-block;width:1.25rem;height:1.25rem;padding:.625rem;background:currentcolor;color:var(--copy-code-c-text);font-size:1.25rem;-webkit-mask-image:var(--code-copy-icon);mask-image:var(--code-copy-icon);-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.vp-copy-code-button:focus,.vp-copy-code-button.copied{opacity:1}.vp-copy-code-button:hover,.vp-copy-code-button.copied{background:var(--copy-code-c-hover)}.vp-copy-code-button.copied:before{-webkit-mask-image:var(--code-copied-icon);mask-image:var(--code-copied-icon)}.vp-copy-code-button.copied:after{content:attr(data-copied);position:absolute;top:0;right:calc(100% + .25rem);display:block;height:1.25rem;padding:.625rem;border-radius:.5rem;background:var(--copy-code-c-hover);color:var(--copy-code-c-text);font-weight:500;line-height:1.25rem;white-space:nowrap}.no-copy-code .vp-copy-code-button{display:none}body:not(.no-copy-code) div[class*=language-]:hover:before{display:none}body:not(.no-copy-code) div[class*=language-]:hover .vp-copy-code-button{opacity:1}:root{--code-copy-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23808080' stroke-width='2'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2' /%3e%3c/svg%3e");--code-copied-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23808080' stroke-width='2'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2m-6 9 2 2 4-4' /%3e%3c/svg%3e");--copy-code-c-text: var(--code-c-line-number);--copy-code-c-hover: var(--code-c-highlight-bg)}.hint-container{position:relative;background:var(--hint-c-soft);transition:background var(--vp-t-color),color var(--vp-t-color)}@media print{.hint-container{page-break-inside:avoid}}.hint-container>.hint-container-title{color:var(--hint-c-title)}.hint-container :not(pre)>code{background:var(--hint-c-soft)}.hint-container .hint-container-title{position:relative;margin-block:.75em;font-weight:600;line-height:1.25}.hint-container.important,.hint-container.info,.hint-container.note,.hint-container.tip,.hint-container.warning,.hint-container.caution{margin-block:.75rem;padding:.25em 1em;border-radius:.5em;color:inherit;font-size:var(--hint-font-size)}@media print{.hint-container.important,.hint-container.info,.hint-container.note,.hint-container.tip,.hint-container.warning,.hint-container.caution{border-inline-start-width:.25em;border-inline-start-style:solid}}.hint-container.important .hint-container-title,.hint-container.info .hint-container-title,.hint-container.note .hint-container-title,.hint-container.tip .hint-container-title,.hint-container.warning .hint-container-title,.hint-container.caution .hint-container-title{padding-inline-start:1.75em}@media print{.hint-container.important .hint-container-title,.hint-container.info .hint-container-title,.hint-container.note .hint-container-title,.hint-container.tip .hint-container-title,.hint-container.warning .hint-container-title,.hint-container.caution .hint-container-title{padding-inline-start:0}}.hint-container.important .hint-container-title:before,.hint-container.info .hint-container-title:before,.hint-container.note .hint-container-title:before,.hint-container.tip .hint-container-title:before,.hint-container.warning .hint-container-title:before,.hint-container.caution .hint-container-title:before{content:" ";position:absolute;inset-inline-start:0;top:calc(50% - .6125em);width:1.25em;height:1.25em;font-size:1.25em}@media print{.hint-container.important .hint-container-title:before,.hint-container.info .hint-container-title:before,.hint-container.note .hint-container-title:before,.hint-container.tip .hint-container-title:before,.hint-container.warning .hint-container-title:before,.hint-container.caution .hint-container-title:before{display:none}}.hint-container.important p,.hint-container.info p,.hint-container.note p,.hint-container.tip p,.hint-container.warning p,.hint-container.caution p{line-height:1.5}.hint-container.important a,.hint-container.info a,.hint-container.note a,.hint-container.tip a,.hint-container.warning a,.hint-container.caution a{color:var(--vp-c-accent)}.hint-container.important{--hint-c-accent: var(--important-c-accent);--hint-c-title: var(--important-c-text);--hint-c-soft: var(--important-c-soft)}.hint-container.important>.hint-container-title:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 981.333a84.992 84.992 0 0 1-84.907-84.906h169.814A84.992 84.992 0 0 1 512 981.333zm384-128H128v-42.666l85.333-85.334v-256A298.325 298.325 0 0 1 448 177.92V128a64 64 0 0 1 128 0v49.92a298.325 298.325 0 0 1 234.667 291.413v256L896 810.667v42.666zm-426.667-256v85.334h85.334v-85.334h-85.334zm0-256V512h85.334V341.333h-85.334z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 981.333a84.992 84.992 0 0 1-84.907-84.906h169.814A84.992 84.992 0 0 1 512 981.333zm384-128H128v-42.666l85.333-85.334v-256A298.325 298.325 0 0 1 448 177.92V128a64 64 0 0 1 128 0v49.92a298.325 298.325 0 0 1 234.667 291.413v256L896 810.667v42.666zm-426.667-256v85.334h85.334v-85.334h-85.334zm0-256V512h85.334V341.333h-85.334z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.hint-container.info{--hint-c-accent: var(--info-c-accent);--hint-c-title: var(--info-c-text);--hint-c-soft: var(--info-c-soft)}.hint-container.info>.hint-container-title:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.hint-container.note{--hint-c-accent: var(--note-c-accent);--hint-c-title: var(--note-c-text);--hint-c-soft: var(--note-c-soft)}.hint-container.note>.hint-container-title:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-11v6h2v-6h-2zm0-4v2h2V7h-2z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.hint-container.tip{--hint-c-accent: var(--tip-c-accent);--hint-c-title: var(--tip-c-text);--hint-c-soft: var(--tip-c-soft)}.hint-container.tip>.hint-container-title:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7.941 18c-.297-1.273-1.637-2.314-2.187-3a8 8 0 1 1 12.49.002c-.55.685-1.888 1.726-2.185 2.998H7.94zM16 20v1a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-1h8zm-3-9.995V6l-4.5 6.005H11v4l4.5-6H13z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7.941 18c-.297-1.273-1.637-2.314-2.187-3a8 8 0 1 1 12.49.002c-.55.685-1.888 1.726-2.185 2.998H7.94zM16 20v1a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-1h8zm-3-9.995V6l-4.5 6.005H11v4l4.5-6H13z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.hint-container.warning{--hint-c-accent: var(--warning-c-accent);--hint-c-title: var(--warning-c-text);--hint-c-soft: var(--warning-c-soft)}.hint-container.warning>.hint-container-title:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M576.286 752.57v-95.425q0-7.031-4.771-11.802t-11.3-4.772h-96.43q-6.528 0-11.3 4.772t-4.77 11.802v95.424q0 7.031 4.77 11.803t11.3 4.77h96.43q6.528 0 11.3-4.77t4.77-11.803zm-1.005-187.836 9.04-230.524q0-6.027-5.022-9.543-6.529-5.524-12.053-5.524H456.754q-5.524 0-12.053 5.524-5.022 3.516-5.022 10.547l8.538 229.52q0 5.023 5.022 8.287t12.053 3.265h92.913q7.032 0 11.803-3.265t5.273-8.287zM568.25 95.65l385.714 707.142q17.578 31.641-1.004 63.282-8.538 14.564-23.354 23.102t-31.892 8.538H126.286q-17.076 0-31.892-8.538T71.04 866.074q-18.582-31.641-1.004-63.282L455.75 95.65q8.538-15.57 23.605-24.61T512 62t32.645 9.04 23.605 24.61z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M576.286 752.57v-95.425q0-7.031-4.771-11.802t-11.3-4.772h-96.43q-6.528 0-11.3 4.772t-4.77 11.802v95.424q0 7.031 4.77 11.803t11.3 4.77h96.43q6.528 0 11.3-4.77t4.77-11.803zm-1.005-187.836 9.04-230.524q0-6.027-5.022-9.543-6.529-5.524-12.053-5.524H456.754q-5.524 0-12.053 5.524-5.022 3.516-5.022 10.547l8.538 229.52q0 5.023 5.022 8.287t12.053 3.265h92.913q7.032 0 11.803-3.265t5.273-8.287zM568.25 95.65l385.714 707.142q17.578 31.641-1.004 63.282-8.538 14.564-23.354 23.102t-31.892 8.538H126.286q-17.076 0-31.892-8.538T71.04 866.074q-18.582-31.641-1.004-63.282L455.75 95.65q8.538-15.57 23.605-24.61T512 62t32.645 9.04 23.605 24.61z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.hint-container.caution{--hint-c-accent: var(--caution-c-accent);--hint-c-title: var(--caution-c-text);--hint-c-soft: var(--caution-c-soft)}.hint-container.caution>.hint-container-title:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c5.523 0 10 4.477 10 10v3.764a2 2 0 0 1-1.106 1.789L18 19v1a3 3 0 0 1-2.824 2.995L14.95 23a2.5 2.5 0 0 0 .044-.33L15 22.5V22a2 2 0 0 0-1.85-1.995L13 20h-2a2 2 0 0 0-1.995 1.85L9 22v.5c0 .171.017.339.05.5H9a3 3 0 0 1-3-3v-1l-2.894-1.447A2 2 0 0 1 2 15.763V12C2 6.477 6.477 2 12 2zm-4 9a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm8 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.hint-container.details{position:relative;display:block;margin-block:.75rem;padding:1.25rem 1rem;border-radius:.5rem;background:var(--detail-c-bg);transition:background var(--vp-t-transform),color var(--vp-t-transform)}.hint-container.details h4{margin-top:0}.hint-container.details figure:last-child,.hint-container.details p:last-child{margin-bottom:0;padding-bottom:0}.hint-container.details a{color:var(--vp-c-accent)}.hint-container.details :not(pre)>code{background:var(--detail-c-soft)}.hint-container.details summary{position:relative;margin:-1rem;padding-block:1em;padding-inline:3em 1.5em;list-style:none;font-size:var(--hint-font-size);cursor:pointer}.hint-container.details summary::-webkit-details-marker{display:none}.hint-container.details summary::marker{color:#0000;font-size:0}.hint-container.details summary:before{background-color:currentColor;-webkit-mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'/%3E%3C/svg%3E");mask-image:url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'/%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:1em;mask-size:1em;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;content:" ";position:absolute;inset-inline-start:.8em;top:calc(50% - .5em);width:1em;height:1em;font-size:1.25rem;line-height:normal;transition:color,var(--vp-t-color),transform var(--vp-t-transform);transform:rotate(90deg)}.hint-container.details[open]>summary{margin-bottom:.5em}.hint-container.details[open]>summary:before{transform:rotate(180deg)}:root{--hint-font-size: .92rem;--important-c-accent: var(--vp-c-purple-bg);--important-c-text: var(--vp-c-purple-text);--important-c-soft: var(--vp-c-purple-soft);--info-c-accent: var(--vp-c-blue-bg);--info-c-text: var(--vp-c-blue-text);--info-c-soft: var(--vp-c-blue-soft);--note-c-accent: var(--vp-c-grey-bg);--note-c-text: var(--vp-c-grey-text);--note-c-soft: var(--vp-c-grey-soft);--tip-c-accent: var(--vp-c-green-bg);--tip-c-text: var(--vp-c-green-text);--tip-c-soft: var(--vp-c-green-soft);--warning-c-accent: var(--vp-c-yellow-bg);--warning-c-text: var(--vp-c-yellow-text);--warning-c-soft: var(--vp-c-yellow-soft);--caution-c-accent: var(--vp-c-red-bg);--caution-c-text: var(--vp-c-red-text);--caution-c-soft: var(--vp-c-red-soft);--detail-c-bg: var(--vp-c-control);--detail-c-icon: var(--vp-c-border);--detail-c-soft: var(--vp-c-grey-soft)}:root{--medium-zoom-z-index: 100;--medium-zoom-c-bg: var(--vp-c-bg-elv, #fff);--medium-zoom-opacity: 1}.medium-zoom-overlay{z-index:var(--medium-zoom-z-index);background-color:var(--medium-zoom-c-bg)!important}.medium-zoom-overlay~img{z-index:calc(var(--medium-zoom-z-index) + 1)}.medium-zoom--opened .medium-zoom-overlay{opacity:var(--medium-zoom-opacity)}:root{--nprogress-c: var(--vp-c-accent);--nprogress-z-index: 1031}#nprogress{pointer-events:none}#nprogress .bar{position:fixed;top:0;left:0;z-index:var(--nprogress-z-index);width:100%;height:2px;background:var(--nprogress-c)}:root{--code-padding-x: 1.25rem;--code-padding-y: 1rem;--code-border-radius: 6px;--code-line-height: 1.6;--code-font-size: 14px;--code-font-family: consolas, monaco, "Andale Mono", "Ubuntu Mono", monospace}div[class*=language-]{position:relative;border-radius:var(--code-border-radius);background-color:var(--code-c-bg)}div[class*=language-]:before{content:attr(data-title);position:absolute;top:.8em;right:1em;z-index:3;color:var(--code-c-text);font-size:.75rem}div[class*=language-] pre{position:relative;z-index:1;overflow-x:auto;margin:0;border-radius:var(--code-border-radius);font-size:var(--code-font-size);font-family:var(--code-font-family);line-height:var(--code-line-height)}div[class*=language-] pre code{display:block;box-sizing:border-box;width:-moz-fit-content;width:fit-content;min-width:100%;padding:var(--code-padding-y) var(--code-padding-x);background-color:#0000!important;color:var(--code-c-text);overflow-wrap:unset;-webkit-font-smoothing:auto;-moz-osx-font-smoothing:auto}:root{--code-c-text: #f8f8f2;--code-c-bg: #2e3440;--code-c-highlight-bg: rgb(51.6454545455, 60.5484848485, 78.3545454545);--code-c-line-number: rgba(248, 248, 242, .67)}.token.comment,.token.prolog,.token.doctype,.token.cdata{color:#636f88}.token.punctuation{color:#81a1c1}.namespace{opacity:.7}.token.property,.token.tag,.token.constant,.token.symbol,.token.deleted{color:#81a1c1}.token.number{color:#b48ead}.token.boolean{color:#81a1c1}.token.selector,.token.attr-name,.token.string,.token.char,.token.builtin,.token.inserted{color:#a3be8c}.token.operator,.token.entity,.token.url,.language-css .token.string,.style .token.string,.token.variable{color:#81a1c1}.token.atrule,.token.attr-value,.token.function,.token.class-name{color:#88c0d0}.token.keyword{color:#81a1c1}.token.regex,.token.important{color:#ebcb8b}.token.important,.token.bold{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}:root{--code-line-number-width: 3rem}div[class*=language-]:not(.line-numbers-mode) .line-numbers{display:none}div[class*=language-].line-numbers-mode:after{content:"";position:absolute;top:0;left:0;width:var(--code-line-number-width);height:100%;border-right:1px solid var(--code-c-highlight-bg, var(--code-c-text));border-radius:var(--code-border-radius) 0 0 var(--code-border-radius);transition:border var(--vp-t-color)}div[class*=language-].line-numbers-mode pre{vertical-align:middle;margin-left:var(--code-line-number-width)}div[class*=language-].line-numbers-mode code{padding-left:1rem}div[class*=language-].line-numbers-mode .line-numbers{counter-reset:line-number;position:absolute;top:0;width:var(--code-line-number-width);padding-top:var(--code-padding-y);color:var(--code-c-line-number, var(--code-c-text));font-size:var(--code-font-size);line-height:var(--code-line-height);text-align:center}div[class*=language-].line-numbers-mode .line-number{position:relative;z-index:3;font-family:var(--code-font-family);-webkit-user-select:none;-moz-user-select:none;user-select:none}div[class*=language-].line-numbers-mode .line-number:before{content:counter(line-number);counter-increment:line-number}div[class*=language-] .line.highlighted{display:inline-block;width:100%;margin:0 calc(-1*var(--code-padding-x));padding:0 var(--code-padding-x);background-color:var(--code-c-highlight-bg)}div[class*=language-].has-collapsed-lines.collapsed{overflow-y:hidden;height:calc(var(--vp-collapsed-lines)*var(--code-line-height)*var(--code-font-size) + var(--code-padding-y) + 28px)}div[class*=language-].has-collapsed-lines .collapsed-lines{--vp-collapsed-lines-bg: var(--code-c-bg);position:absolute;right:0;bottom:0;left:0;z-index:4;display:flex;align-items:center;justify-content:center;height:28px;background:linear-gradient(to bottom,transparent 0%,var(--vp-collapsed-lines-bg) 55%,var(--vp-collapsed-lines-bg) 100%);cursor:pointer;transition:--vp-collapsed-lines-bg var(--vp-t-color)}div[class*=language-].has-collapsed-lines .collapsed-lines:hover{--vp-collapsed-lines-bg: rgb(0 0 0 / 10%) !important}div[class*=language-].has-collapsed-lines[data-highlighter=shiki] .collapsed-lines{--vp-collapsed-lines-bg: var(--code-c-bg, var(--shiki-light-bg))}[data-theme=dark] div[class*=language-].has-collapsed-lines[data-highlighter=shiki] .collapsed-lines{--vp-collapsed-lines-bg: var(--code-c-bg, var(--shiki-dark-bg))}div[class*=language-].has-collapsed-lines .collapsed-lines:before{--icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23000' stroke-width='2' d='m18 12l-6 6l-6-6m12-6l-6 6l-6-6'/%3E%3C/svg%3E");--vp-collapsed-lines-rotate: 0deg;content:"";display:inline-block;width:24px;height:24px;background-color:var(--code-c-text);-webkit-mask-image:var(--icon);mask-image:var(--icon);-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:20px;mask-size:20px;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;pointer-events:none;animation:code-collapsed-lines 1.2s infinite alternate-reverse ease-in-out}div[class*=language-].has-collapsed-lines:not(.collapsed) code{padding-bottom:max(var(--code-padding-y),28px)}div[class*=language-].has-collapsed-lines:not(.collapsed) .collapsed-lines:hover{--vp-collapsed-lines-bg: transparent !important}div[class*=language-].has-collapsed-lines:not(.collapsed) .collapsed-lines:before{--vp-collapsed-lines-rotate: 180deg}@property --vp-collapsed-lines-bg{inherits:false;initial-value:#fff;syntax:""}@keyframes code-collapsed-lines{0%{opacity:.3;transform:translateY(-2px) rotate(var(--vp-collapsed-lines-rotate))}to{opacity:1;transform:translateY(2px) rotate(var(--vp-collapsed-lines-rotate))}}.vp-code-tabs-nav{overflow-x:auto;margin:.75rem 0 -.75rem;padding:0;border-radius:6px 6px 0 0;background:var(--code-tabs-c-bg);list-style:none;white-space:nowrap;transition:background var(--vp-t-color)}@media print{.vp-code-tabs-nav{display:none}}@media (max-width: 419px){.vp-code-tabs-nav{margin-inline:-1.5rem;border-radius:0}}.vp-code-tab-nav{position:relative;min-width:3rem;margin:0;padding:6px 12px;border-width:0;border-radius:6px 6px 0 0;background:#0000;color:var(--code-tabs-c-text);font-weight:600;font-size:.875em;line-height:1.4;cursor:pointer;transition:background var(--vp-t-color),color var(--vp-t-color)}.vp-code-tab-nav:hover{background:var(--code-tabs-c-hover)}.vp-code-tab-nav:before,.vp-code-tab-nav:after{content:" ";position:absolute;bottom:0;z-index:1;width:6px;height:6px}.vp-code-tab-nav:before{right:100%}.vp-code-tab-nav:after{left:100%}.vp-code-tab-nav.active{background:var(--code-c-bg, var(--vp-c-bg-alt))}.vp-code-tab-nav.active:before{background:radial-gradient(12px at left top,transparent 50%,var(--code-c-bg) 50%)}.vp-code-tab-nav.active:after{background:radial-gradient(12px at right top,transparent 50%,var(--code-c-bg) 50%)}.vp-code-tab-nav:first-child:before{display:none}[dir=rtl] .vp-code-tab-nav:first-child:before{display:block}[dir=rtl] .vp-code-tab-nav:first-child:after{display:none}.vp-code-tab{display:none}@media print{.vp-code-tab{display:block}}.vp-code-tab.active{display:block}.vp-code-tab div[class*=language-]{border-top-left-radius:0;border-top-right-radius:0}@media (max-width: 419px){.vp-code-tab div[class*=language-]{margin:.75rem -1.5rem;border-radius:0}}.vp-code-tab div[class*=language-].line-numbers-mode:after{border-top-left-radius:0}.vp-code-tab div[class*=language-] pre{border-top-left-radius:0;border-top-right-radius:0}@media (max-width: 419px){.vp-code-tab div[class*=language-] pre{border-radius:0}}@media print{.vp-code-tab div[class*=language-] code{white-space:pre-wrap}}.vp-code-tab-title{display:none;font-weight:500}@media print{.vp-code-tab-title{display:block}}.vp-tabs{margin:1.5rem 0;border:1px solid var(--vp-c-border);border-radius:8px}@media (max-width: 419px){[vp-content]>.vp-tabs{margin-inline:-1.5rem;border:none;border-bottom:1px solid var(--vp-c-border);border-radius:0}}.vp-tabs-nav{overflow-x:auto;margin:0;padding:0;border-radius:.5rem .5rem 0 0;background:var(--tab-c-bg-nav);list-style:none;white-space:nowrap;transition:background var(--vp-t-color)}@media print{.vp-tabs-nav{display:none}}@media (max-width: 419px){.vp-tabs-nav{border-radius:0}}.vp-tab-nav{position:relative;min-width:4rem;margin:0;padding:.5em 1em;border:none;border-radius:.5rem .5rem 0 0;background:#0000;color:var(--tab-c-nav);font-weight:600;font-size:.875em;line-height:1.75;cursor:pointer;transition:background var(--vp-t-color),color var(--vp-t-color)}.vp-tab-nav:hover{background:var(--tab-c-bg-nav-hover)}.vp-tab-nav:before,.vp-tab-nav:after{content:" ";position:absolute;bottom:0;z-index:1;width:8px;height:8px}.vp-tab-nav:before{right:100%}.vp-tab-nav:after{left:100%}.vp-tab-nav.active{background:var(--tab-c-bg)}.vp-tab-nav.active:before{background:radial-gradient(16px at left top,transparent 50%,var(--tab-c-bg) 50%)}.vp-tab-nav.active:after{background:radial-gradient(16px at right top,transparent 50%,var(--tab-c-bg) 50%)}.vp-tab-nav:first-child:before{display:none}.vp-tab{display:none;padding:1rem .75rem;border-radius:0 0 .5rem .5rem;background:var(--tab-c-bg);transition:background var(--vp-t-color)}@media print{.vp-tab{display:block;padding:.5rem}}.vp-tab.active{display:block}.vp-tab:nth-child(n+2) .vp-tab-title{border-top:none}.vp-tab-title{display:none;padding:.25rem 0;border-top:1px solid var(--vp-c-border);font-weight:500}@media print{.vp-tab-title{display:block}}:root{--code-tabs-c-text: var(--code-c-text);--code-tabs-c-bg: var(--code-c-highlight-bg);--code-tabs-c-hover: var(--code-c-bg, var(--vp-c-bg-alt));--tab-c-bg: var(--vp-c-bg);--tab-c-nav: var(--vp-c-text);--tab-c-bg-nav: var(--vp-c-grey-bg);--tab-c-bg-nav-hover: var(--vp-c-control-hover)}.vp-badge{display:inline-block;vertical-align:top;height:18px;padding:0 6px;border-radius:3px;background:var(--vp-c-accent-soft);color:var(--vp-c-accent);font-size:14px;line-height:18px;transition:background var(--vp-t-color),color var(--vp-t-color)}.vp-badge+.vp-badge{margin-inline-start:5px}.vp-badge.tip{background:var(--badge-c-tip-bg);color:var(--badge-c-tip-text)}.vp-badge.warning{background:var(--badge-c-warning-bg);color:var(--badge-c-warning-text)}.vp-badge.danger{background:var(--badge-c-danger-bg);color:var(--badge-c-danger-text)}.vp-badge.important{background:var(--badge-c-important-bg);color:var(--badge-c-important-text)}.vp-badge.info{background:var(--badge-c-info-bg);color:var(--badge-c-info-text)}.vp-badge.note{background:var(--badge-c-note-bg);color:var(--badge-c-note-text)}.vp-features{display:flex;flex-wrap:wrap;place-content:stretch space-between;align-items:flex-start;margin-top:2.5rem;padding:1.2rem 0;border-top:1px solid var(--vp-c-gutter);transition:border-color var(--vp-t-color)}@media (max-width: 719px){.vp-features{flex-direction:column}}.vp-feature{flex-grow:1;flex-basis:30%;max-width:30%}@media (max-width: 719px){.vp-feature{max-width:100%;padding:0 2.5rem}}.vp-feature h2{padding-bottom:0;border-bottom:none;font-weight:500;font-size:1.4rem}@media (max-width: 419px){.vp-feature h2{font-size:1.25rem}}.vp-feature p{color:var(--vp-c-text-mute)}.vp-footer{padding:2.5rem;border-top:1px solid var(--vp-c-border);color:var(--vp-c-text-mute);text-align:center;transition:border-color var(--vp-t-color)}.vp-hero{text-align:center}.vp-hero-image{display:block;max-width:100%;max-height:280px;margin:3rem auto 1.5rem}@media (max-width: 419px){.vp-hero-image{max-height:210px;margin:2rem auto 1.2rem}}#main-title{font-size:3rem}@media (max-width: 419px){#main-title{font-size:2rem}}#main-title,.vp-hero-description,.vp-hero-actions{margin:1.8rem auto}@media (max-width: 419px){#main-title,.vp-hero-description,.vp-hero-actions{margin:1.2rem auto}}.vp-hero-actions{display:flex;flex-wrap:wrap;gap:1rem;justify-content:center}.vp-hero-description{max-width:35rem;color:var(--vp-c-text-mute);font-size:1.6rem;line-height:1.3}@media (max-width: 419px){.vp-hero-description{font-size:1.2rem}}.vp-hero-action-button{display:inline-block;box-sizing:border-box;padding:.8rem 1.6rem;border:2px solid var(--vp-c-accent-bg);border-radius:4px;background-color:var(--vp-c-bg);color:var(--vp-c-accent);font-size:1.2rem;transition:background-color border-color color var(--vp-t-color)}@media (max-width: 419px){.vp-hero-action-button{padding:.6rem 1.2rem;font-size:1rem}}.vp-hero-action-button:hover{color:var(--vp-c-accent-text)}.vp-hero-action-button.primary{background-color:var(--vp-c-accent-bg);color:var(--vp-c-accent-text)}.vp-hero-action-button.primary:hover{border-color:var(--vp-c-accent-hover);background-color:var(--vp-c-accent-hover)}.vp-home{display:block;max-width:var(--homepage-width);margin:0 auto;padding:var(--navbar-height) 2rem 0}@media (max-width: 419px){.vp-home{padding-right:1.5rem;padding-left:1.5rem}}.vp-home .theme-default-content{margin:0;padding:0}.vp-site-logo{vertical-align:top;height:var(--navbar-line-height);margin-right:var(--navbar-padding-v)}.vp-site-name{position:relative;color:var(--vp-c-text);font-weight:600;font-size:1.3rem}@media screen and (max-width: 719px){.vp-site-name{display:block;overflow:hidden;width:calc(100vw - 11rem);text-overflow:ellipsis;white-space:nowrap}}.vp-dropdown-enter-from,.vp-dropdown-leave-to{height:0!important}.vp-navbar-dropdown-wrapper{cursor:pointer}.vp-navbar-dropdown-wrapper:not(.mobile){height:1.8rem}.vp-navbar-dropdown-wrapper:not(.mobile):hover .vp-navbar-dropdown,.vp-navbar-dropdown-wrapper:not(.mobile).open .vp-navbar-dropdown{display:block!important}.vp-navbar-dropdown-wrapper.mobile .vp-navbar-dropdown{overflow:hidden;transition:height .1s ease-out;padding-top:.5rem}.vp-navbar-dropdown-wrapper:not(.mobile) .vp-navbar-dropdown{position:absolute;top:100%;right:0;display:none;overflow-y:auto;box-sizing:border-box;height:auto!important;max-height:calc(100vh - 2.7rem);margin:0;padding:.6rem 0;border:1px solid var(--vp-c-gutter);border-radius:.5rem;background-color:var(--vp-c-bg-elv);text-align:left;white-space:nowrap}.vp-navbar-dropdown-title{display:block;padding:inherit;border:none;background:transparent;color:var(--vp-c-text);font-weight:500;font-size:.9rem;font-family:inherit;line-height:1.4rem;cursor:inherit}.vp-navbar-dropdown-wrapper.mobile .vp-navbar-dropdown-title{display:none}.vp-navbar-dropdown-title:hover{border-color:transparent}.vp-navbar-dropdown-title-mobile{display:none;padding:inherit;border:none;background:transparent;color:var(--vp-c-text);font-weight:600;font-size:inherit;font-family:inherit;line-height:1.4rem;cursor:inherit}.vp-navbar-dropdown-wrapper.mobile .vp-navbar-dropdown-title-mobile{display:block}.vp-navbar-dropdown-title-mobile:hover{color:var(--vp-c-accent)}.vp-navbar-dropdown-item{color:inherit;line-height:1.7rem}.vp-navbar-dropdown-item a{position:relative;display:block;margin-bottom:0;padding:0 1.5rem 0 1.25rem;border-bottom:none;font-weight:400;line-height:1.7rem}.vp-navbar-dropdown-item a:hover,.vp-navbar-dropdown-item a.route-link-active{color:var(--vp-c-accent)}.vp-navbar-dropdown-item a.route-link-active:after{content:"";position:absolute;top:calc(50% - 2px);left:9px;width:0;height:0;border-top:3px solid transparent;border-bottom:3px solid transparent;border-left:5px solid var(--vp-c-accent)}.vp-navbar-dropdown-wrapper.mobile .vp-navbar-dropdown-item>a{font-size:15px;line-height:2rem}.vp-navbar-dropdown-subtitle{margin:.45rem 0 0;padding:1rem 0 .45rem;border-top:1px solid var(--vp-c-gutter);font-size:.9rem}.vp-navbar-dropdown-wrapper.mobile .vp-navbar-dropdown-subtitle{margin-top:0;padding-top:0;padding-bottom:0;border-top:0;font-size:15px;line-height:2rem}.vp-navbar-dropdown-item:first-child .vp-navbar-dropdown-subtitle{margin-top:0;padding-top:0;border-top:0}.vp-navbar-dropdown-subtitle>span{padding:0 1.5rem 0 1.25rem}.vp-navbar-dropdown-subtitle>a{font-weight:inherit}.vp-navbar-dropdown-subtitle>a.route-link-active:after{display:none}.vp-navbar-dropdown-subitem-wrapper{padding:0;list-style:none}.vp-navbar-dropdown-subitem{font-size:.9em}.vp-navbar-dropdown-wrapper.mobile .vp-navbar-dropdown-subitem{padding-left:1rem;font-size:14px}.vp-navbar-items{display:inline-block}@media print{.vp-navbar-items{display:none}}.vp-navbar-items a{display:inline-block;color:inherit;line-height:1.4rem}.vp-navbar-items a:hover,.vp-navbar-items a.route-link-active{color:var(--vp-c-text)}.vp-navbar-item{position:relative;display:inline-block;margin-left:1.5rem;line-height:var(--navbar-line-height)}@media (max-width: 719px){.vp-navbar-item{margin-left:0}}.vp-navbar-item:first-child{margin-left:0}.vp-navbar-item a:hover,.vp-navbar-item a.route-link-active{color:var(--vp-c-accent)}.vp-navbar-item>a:hover,.vp-navbar-item>a.route-link-active{margin-bottom:-2px;border-bottom:2px solid var(--vp-c-accent)}@media (max-width: 719px){.vp-navbar-item>a:hover,.vp-navbar-item>a.route-link-active{margin-bottom:0;border-bottom:none}}.vp-toggle-color-mode-button{display:flex;margin:auto;margin-left:1rem;border:0;background:none;color:var(--vp-c-text);opacity:.8;cursor:pointer}@media print{.vp-toggle-color-mode-button{display:none}}.vp-toggle-color-mode-button:hover{opacity:1}.vp-toggle-color-mode-button .light-icon,.vp-toggle-color-mode-button .dark-icon{width:1.25rem;height:1.25rem}.vp-toggle-sidebar-button{position:absolute;top:.6rem;left:1rem;display:none;padding:.6rem;cursor:pointer}@media screen and (max-width: 719px){.vp-toggle-sidebar-button{display:block}}.vp-toggle-sidebar-button .icon{display:flex;flex-direction:column;align-items:center;justify-content:center;width:1.25rem;height:1.25rem;cursor:inherit}.vp-toggle-sidebar-button .icon span{display:inline-block;width:100%;height:2px;border-radius:2px;background-color:var(--vp-c-text);transition:transform var(--vp-t-transform)}.vp-toggle-sidebar-button .icon span:nth-child(2){margin:6px 0}.vp-theme-container.sidebar-open .vp-toggle-sidebar-button .icon span:nth-child(1){transform:rotate(45deg) translate3d(5.5px,5.5px,0)}.vp-theme-container.sidebar-open .vp-toggle-sidebar-button .icon span:nth-child(2){transform:scale3d(0,1,1)}.vp-theme-container.sidebar-open .vp-toggle-sidebar-button .icon span:nth-child(3){transform:rotate(-45deg) translate3d(6px,-6px,0)}.vp-theme-container.sidebar-open .vp-toggle-sidebar-button .icon span:nth-child(1),.vp-theme-container.sidebar-open .vp-toggle-sidebar-button .icon span:nth-child(3){transform-origin:center}.vp-navbar{--navbar-line-height: calc( var(--navbar-height) - 2 * var(--navbar-padding-v) );position:fixed;top:0;right:0;left:0;z-index:20;box-sizing:border-box;height:var(--navbar-height);padding:var(--navbar-padding-v) var(--navbar-padding-h);border-bottom:1px solid var(--vp-c-border);background-color:var(--vp-navbar-c-bg);line-height:var(--navbar-line-height);transition:background-color var(--vp-t-color),border-color var(--vp-t-color)}@media screen and (max-width: 719px){.vp-navbar{padding-left:4rem}}.vp-navbar-items-wrapper{position:absolute;top:var(--navbar-padding-v);right:var(--navbar-padding-h);display:flex;box-sizing:border-box;height:var(--navbar-line-height);padding-left:var(--navbar-padding-h);font-size:.9rem;white-space:nowrap}.vp-page-meta{max-width:var(--content-width);margin:0 auto;padding:2rem 2.5rem}@media (max-width: 959px){.vp-page-meta{padding:2rem}}@media (max-width: 419px){.vp-page-meta{padding:1.5rem}}.vp-page-meta{display:flex;flex-wrap:wrap;justify-content:space-between;overflow:auto;padding-top:.75rem;padding-bottom:.75rem}@media print{.vp-page-meta{margin:0!important;padding-right:0!important;padding-left:0!important}}@media (max-width: 719px){.vp-page-meta{display:block}}.vp-page-meta .vp-meta-item{flex-grow:1}.vp-page-meta .vp-meta-item .vp-meta-label{font-weight:500}.vp-page-meta .vp-meta-item .vp-meta-label:not(a){color:var(--vp-c-text-mute)}.vp-page-meta .vp-meta-item .vp-meta-info{color:var(--vp-c-text-mute);font-weight:400}.vp-page-meta .git-info{text-align:end}.vp-page-meta .edit-link{margin-top:.25rem;margin-right:.5rem;margin-bottom:.25rem;font-size:14px}@media print{.vp-page-meta .edit-link{display:none}}.vp-page-meta .edit-link .edit-icon{position:relative;bottom:-.125em;width:1em;height:1em;margin-right:.25em}.vp-page-meta .last-updated,.vp-page-meta .contributors{margin-top:.25rem;margin-bottom:.25rem;font-size:14px}@media (max-width: 719px){.vp-page-meta .last-updated,.vp-page-meta .contributors{font-size:13px;text-align:start}}.vp-page-nav{display:flex;flex-wrap:wrap;max-width:var(--content-width, 740px);min-height:2rem;margin-top:0;margin-right:auto;margin-left:auto;padding:1rem 2rem 0;border-top:1px solid var(--vp-c-gutter);transition:border-top var(--vp-t-color)}@media (max-width: 959px){.vp-page-nav{padding-right:1rem;padding-left:1rem}}@media print{.vp-page-nav{display:none}}.vp-page-nav .route-link{display:inline-block;flex-grow:1;margin:.25rem;padding:.25rem .5rem;border:1px solid var(--vp-c-gutter);border-radius:.25rem}.vp-page-nav .route-link:hover{background:var(--vp-c-control)}.vp-page-nav .route-link .hint{color:var(--vp-c-text-mute);font-size:.875rem;line-height:2}.vp-page-nav .prev{text-align:start}.vp-page-nav .next{text-align:end}.vp-page{display:block;padding-top:var(--navbar-height);padding-bottom:2rem;padding-left:var(--sidebar-width)}@media (max-width: 959px){.vp-page{padding-left:var(--sidebar-width-mobile)}}@media (max-width: 719px){.vp-page{padding-left:0}}.vp-page .theme-default-content{max-width:var(--content-width);margin:0 auto;padding:2rem 2.5rem}@media (max-width: 959px){.vp-page .theme-default-content{padding:2rem}}@media (max-width: 419px){.vp-page .theme-default-content{padding:1.5rem}}.vp-page .theme-default-content{padding-top:0}.vp-sidebar-item{border-left:.25rem solid transparent;color:var(--vp-c-text);cursor:default}.vp-sidebar-item:focus-visible{outline-width:1px;outline-offset:-1px}.vp-sidebar-item.vp-sidebar-heading{box-sizing:border-box;width:100%;margin:0;padding:.35rem 1.5rem .35rem 1.25rem;font-weight:700;font-size:1.1em;transition:color .15s ease}.vp-sidebar-item.vp-sidebar-heading+.vp-sidebar-children{overflow:hidden;transition:height .1s ease-out;margin-bottom:.75rem}.vp-sidebar-item.collapsible{cursor:pointer}.vp-sidebar-item:not(.vp-sidebar-heading){display:inline-block;box-sizing:border-box;width:100%;margin:0;padding:.35rem 1rem .35rem 2rem;font-weight:400;font-size:1em;line-height:1.4}.vp-sidebar-item:not(.vp-sidebar-heading)+.vp-sidebar-children{padding-left:1rem;font-size:.95em}.vp-sidebar-children .vp-sidebar-children .vp-sidebar-item:not(.vp-sidebar-heading){padding:.25rem 1rem .25rem 1.75rem}.vp-sidebar-children .vp-sidebar-children .vp-sidebar-item:not(.vp-sidebar-heading).active{border-left-color:transparent;font-weight:500}a.vp-sidebar-heading+.vp-sidebar-children .vp-sidebar-item:not(.vp-sidebar-heading).active{border-left-color:transparent}.vp-sidebar-item.active:not(p.vp-sidebar-heading){border-left-color:var(--vp-c-accent);color:var(--vp-c-accent);font-weight:600}a.vp-sidebar-item{cursor:pointer}a.vp-sidebar-item:hover{color:var(--vp-c-accent)}.vp-sidebar-items{margin:0;padding:1.5rem 0;list-style-type:none}@media (max-width: 719px){.vp-sidebar-items{padding:1rem 0}}.vp-sidebar-items ul{margin:0;padding:0;list-style-type:none}.vp-sidebar-items a{display:inline-block}.vp-sidebar{position:fixed;top:var(--navbar-height);bottom:0;left:0;z-index:10;overflow-y:auto;box-sizing:border-box;width:var(--sidebar-width);margin:0;border-right:1px solid var(--vp-c-border);background-color:var(--vp-sidebar-c-bg);font-size:16px;transition:transform var(--vp-t-transform),background-color var(--vp-t-color),border-color var(--vp-t-color);scrollbar-color:var(--vp-c-accent-bg) var(--vp-c-gutter);scrollbar-width:thin}@media (max-width: 959px){.vp-sidebar{width:var(--sidebar-width-mobile);font-size:15px}}@media (max-width: 719px){.vp-sidebar{top:0;padding-top:var(--navbar-height);transform:translate(-100%)}}.vp-sidebar::-webkit-scrollbar{width:7px}.vp-sidebar::-webkit-scrollbar-track{background-color:var(--vp-c-gutter)}.vp-sidebar::-webkit-scrollbar-thumb{background-color:var(--vp-c-accent-bg)}.vp-sidebar .vp-navbar-items{display:none;padding:.5rem 0 .75rem;border-bottom:1px solid var(--vp-c-gutter);transition:border-color var(--vp-t-color)}@media (max-width: 719px){.vp-sidebar .vp-navbar-items{display:block}.vp-sidebar .vp-navbar-items .vp-navbar-dropdown-item a.route-link-active:after{top:calc(1rem - 2px)}}.vp-sidebar .vp-navbar-items ul{margin:0;padding:0;list-style-type:none}.vp-sidebar .vp-navbar-items a{font-weight:600}.vp-sidebar .vp-navbar-item{display:block;padding:.5rem 0 .5rem 1.5rem;font-size:1.1em;line-height:1.25rem}.vp-sidebar-mask{position:fixed;top:0;left:0;z-index:9;display:none;width:100vw;height:100vh}.vp-theme-container.no-navbar .vp-sidebar{top:0}@media (max-width: 719px){.vp-theme-container.no-navbar .vp-sidebar{padding-top:0}}.vp-theme-container.no-navbar .vp-page{padding-top:0}.vp-theme-container.no-navbar .theme-default-content h1,.vp-theme-container.no-navbar .theme-default-content h2,.vp-theme-container.no-navbar .theme-default-content h3,.vp-theme-container.no-navbar .theme-default-content h4,.vp-theme-container.no-navbar .theme-default-content h5,.vp-theme-container.no-navbar .theme-default-content h6{margin-top:1.5rem;padding-top:0}.vp-theme-container.no-sidebar .vp-sidebar{display:none}@media (max-width: 719px){.vp-theme-container.no-sidebar .vp-sidebar{display:block}}.vp-theme-container.no-sidebar .vp-page{padding-left:0}@media (max-width: 719px){.vp-theme-container.sidebar-open .vp-sidebar{transform:translate(0)}.vp-theme-container.sidebar-open .vp-sidebar-mask{display:block}}.fade-slide-y-enter-active{transition:all .2s ease}.fade-slide-y-leave-active{transition:all .2s cubic-bezier(1,.5,.8,1)}.fade-slide-y-enter-from,.fade-slide-y-leave-to{opacity:0;transform:translateY(10px)}.vp-theme-container[data-v-67c08c1d]{max-width:740px;margin:0 auto;padding:2rem 2.5rem}@media (max-width: 959px){.vp-theme-container[data-v-67c08c1d]{padding:2rem}}:root{--vp-c-white: #fff;--vp-c-black: #000;--vp-c-grey-text: #656869;--vp-c-grey-hover: #e4e4e9;--vp-c-grey-bg: #ebebef;--vp-c-grey-soft: rgb(142 150 170 / 14%);--vp-c-indigo-text: #3451b2;--vp-c-indigo-hover: #3a5ccc;--vp-c-indigo-bg: #5672cd;--vp-c-indigo-soft: rgb(100 108 255 / 14%);--vp-c-purple-text: #6f42c1;--vp-c-purple-hover: #7e4cc9;--vp-c-purple-bg: #8e5cd9;--vp-c-purple-soft: rgb(159 122 234 / 14%);--vp-c-blue-text: #2888a7;--vp-c-blue-hover: #2d98ba;--vp-c-blue-bg: #2fa1c5;--vp-c-blue-soft: rgb(27 178 229 / 14%);--vp-c-green-text: #18794e;--vp-c-green-hover: #299764;--vp-c-green-bg: #30a46c;--vp-c-green-soft: rgb(16 185 129 / 14%);--vp-c-yellow-text: #915930;--vp-c-yellow-hover: #946300;--vp-c-yellow-bg: #c28100;--vp-c-yellow-soft: rgb(234 179 8 / 14%);--vp-c-red-text: #b8272c;--vp-c-red-hover: #d5393e;--vp-c-red-bg: #e0575b;--vp-c-red-soft: rgb(244 63 94 / 14%)}[data-theme=dark]{--vp-c-white: #000;--vp-c-black: #fff;--vp-c-grey-text: #939499;--vp-c-grey-hover: #414853;--vp-c-grey-bg: #32363f;--vp-c-grey-soft: rgb(101 117 133 / 16%);--vp-c-indigo-text: #a8b1ff;--vp-c-indigo-hover: #5c73e7;--vp-c-indigo-bg: #3e63dd;--vp-c-indigo-soft: rgb(100 108 255 / 16%);--vp-c-blue-text: #c9e8f2;--vp-c-blue-hover: #a6d9ea;--vp-c-blue-bg: #2785a3;--vp-c-blue-soft: rgb(27 178 229 / 16%);--vp-c-purple-text: #c8abfa;--vp-c-purple-hover: #a879e6;--vp-c-purple-bg: #8e5cd9;--vp-c-purple-soft: rgb(159 122 234 / 16%);--vp-c-green-text: #3dd68c;--vp-c-green-hover: #30a46c;--vp-c-green-bg: #298459;--vp-c-green-soft: rgb(16 185 129 / 16%);--vp-c-yellow-text: #f9b44e;--vp-c-yellow-hover: #da8b17;--vp-c-yellow-bg: #a46a0a;--vp-c-yellow-soft: rgb(234 179 8 / 16%);--vp-c-red-text: #f66f81;--vp-c-red-hover: #f14158;--vp-c-red-bg: #b62a3c;--vp-c-red-soft: rgb(244 63 94 / 16%)}:root{color-scheme:light}[data-theme=dark]{color-scheme:dark}html,body{background:var(--vp-c-bg, #fff);accent-color:var(--vp-c-accent, #299764);transition:background-color var(--vp-t-color)}html{font-size:16px;font-display:optional;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none}@media print{html{font-size:12pt}}html[data-theme=dark]{color-scheme:dark}body{min-height:100vh;margin:0;padding:0;color:var(--vp-c-text, rgb(60, 60, 67));font-size:1rem;font-synthesis:style}h1,h2,h3,h4,h5,h6{font-weight:600;line-height:1.25;overflow-wrap:break-word}h1:focus-visible,h2:focus-visible,h3:focus-visible,h4:focus-visible,h5:focus-visible,h6:focus-visible{outline:none}h1{font-size:2rem}h2{padding-bottom:.3rem;border-bottom:1px solid var(--vp-c-gutter, #e2e2e3);font-size:1.65rem;transition:border-color var(--vp-t-color)}h3{font-size:1.35rem}h4{font-size:1.15rem}h5{font-size:1.05rem}h6{font-size:1rem}p,ul,ol{line-height:1.6;overflow-wrap:break-word}@media print{p,ul,ol{line-height:1.5}}ul,ol{padding-inline-start:1.2em}a{color:var(--vp-c-accent, #299764);font-weight:500;text-decoration:none;overflow-wrap:break-word}a.header-anchor{position:relative;color:inherit;text-decoration:none}a.header-anchor:before{content:"¶";position:absolute;top:.4167em;left:-.75em;display:none;color:var(--vp-c-accent, #299764);font-size:.75em}[dir=rtl] a.header-anchor:before{right:-.75em}a.header-anchor:hover:before{display:block}a.header-anchor:focus-visible{outline:none}a.header-anchor:focus-visible:before{display:block;outline:auto}strong{font-weight:600}blockquote{margin:1rem 0;padding:.25rem 0 .25rem 1rem;border-inline-start:.2rem solid var(--vp-c-border-hard, #b8b8ba);color:var(--vp-c-text-mute, rgba(60, 60, 67, .78));font-size:1rem;overflow-wrap:break-word;transition:border-color var(--vp-t-color),color var(--vp-t-color)}blockquote>p{margin:0}hr{border:0;border-bottom:1px solid var(--vp-c-gutter, #e2e2e3);transition:border-color var(--vp-t-color)}:not(pre)>code{margin:0;padding:3px 6px;border-radius:4px;background:var(--vp-c-grey-soft, rgba(142, 150, 170, .14));font-size:.875em;overflow-wrap:break-word;transition:background-color var(--vp-t-color),color var(--vp-t-color)}p a code{color:var(--vp-c-accent, #299764);font-weight:400}table code{padding:.1rem .4rem}kbd{display:inline-block;min-width:1em;margin-inline:.125rem;padding:.25em;border:1px solid var(--vp-c-border, #c2c2c4);border-radius:.25em;box-shadow:1px 1px 4px 0 var(--vp-c-shadow, rgba(0, 0, 0, .15));line-height:1;letter-spacing:-.1em;text-align:center}table{display:block;overflow-x:auto;margin:1rem 0;border-collapse:collapse}tbody tr:nth-child(odd){background:var(--vp-c-bg-alt, #f6f8fa);transition:background-color var(--vp-t-color)}th,td{padding:.6em 1em;border:1px solid var(--vp-c-border-hard, #d1d4d7);transition:border-color var(--vp-t-color)}pre{text-align:left;direction:ltr;white-space:pre;word-spacing:normal;word-wrap:normal;word-break:normal;overflow-wrap:unset;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;hyphens:none}@media print{pre{white-space:pre-wrap}}pre code{padding:0;border-radius:0}@page{margin:2cm;font-size:12pt;size:a4}@media print{*,:after,:before{box-shadow:none!important;text-shadow:none!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}a{color:inherit;font-weight:inherit!important;font-size:inherit!important;text-decoration:underline}a.header-anchor{text-decoration:none}abbr[title]:after{content:" (" attr(title) ")"}pre{border:1px solid #eee;white-space:pre-wrap!important}pre>code{white-space:pre-wrap!important}blockquote{border-inline-start:.2rem solid #ddd;color:inherit}blockquote,pre{orphans:5;widows:5}img,tr,canvas{page-break-inside:avoid}}@media (prefers-reduced-motion: reduce){*,:before,:after{background-attachment:initial!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important}}:root{--vp-c-accent: #299764;--vp-c-accent-bg: #3eaf7c;--vp-c-accent-hover: #4abf8a;--vp-c-accent-text: var(--vp-c-white);--vp-c-accent-soft: rgb(16 185 129 / 14%);--vp-c-bg: #fff;--vp-c-bg-alt: #f6f6f7;--vp-c-bg-elv: #fff;--vp-c-text: rgb(60 60 67);--vp-c-text-mute: rgb(60 60 67 / 78%);--vp-c-text-subtle: rgb(60 60 67 / 56%);--vp-c-gutter: #e2e2e3;--vp-c-border: #c2c2c4;--vp-c-border-hard: #b8b8ba;--vp-c-shadow: rgb(0 0 0 / 15%);--vp-c-control: rgb(142 150 170 / 10%);--vp-c-control-hover: rgb(142 150 170 / 16%);--vp-c-control-disabled: #eaeaea;--vp-navbar-c-bg: var(--vp-c-bg);--vp-sidebar-c-bg: var(--vp-c-bg);--vp-c-code-tab-title: var(--code-c-text, rgb(255 255 255 / 90%));--vp-c-code-tab-bg: var(--code-bg-color, var(--code-c-bg));--vp-c-code-tab-active: var(--vp-c-accent);--badge-c-tip-text: var(--vp-c-green-text);--badge-c-tip-bg: var(--vp-c-green-soft);--badge-c-warning-text: var(--vp-c-yellow-text);--badge-c-warning-bg: var(--vp-c-yellow-soft);--badge-c-danger-text: var(--vp-c-red-text);--badge-c-danger-bg: var(--vp-c-red-soft);--badge-c-important-text: var(--vp-c-purple-text);--badge-c-important-bg: var(--vp-c-purple-soft);--badge-c-info-text: var(--vp-c-indigo-text);--badge-c-info-bg: var(--vp-c-indigo-soft);--badge-c-note-text: var(--vp-c-grey-text);--badge-c-note-bg: var(--vp-c-grey-soft);--font-family: -apple-system, "BlinkMacSystemFont", "Segoe UI", roboto, oxygen, ubuntu, cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;--navbar-height: 3.6rem;--navbar-padding-v: .7rem;--navbar-padding-h: 1.5rem;--sidebar-width: 20rem;--sidebar-width-mobile: calc(var(--sidebar-width) * .82);--content-width: 740px;--homepage-width: 960px;--header-offset: var(--navbar-height);--vp-t-color: .3s ease;--vp-t-transform: .3s ease;--external-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z'/%3E%3C/svg%3E");--external-link-c-icon: var(--vp-c-text-mute)}[data-theme=dark]{--vp-c-accent: #3dd68c;--vp-c-accent-bg: #3aa675;--vp-c-accent-hover: #349469;--vp-c-accent-soft: rgb(16 185 129 / 16%);--vp-c-bg: #1b1b1f;--vp-c-bg-alt: #161618;--vp-c-bg-elv: #202127;--vp-c-text: rgb(235 235 245 / 86%);--vp-c-text-mute: rgb(235 235 245 / 60%);--vp-c-text-subtle: rgb(235 235 245 / 38%);--vp-c-gutter: #000;--vp-c-border: #3c3f44;--vp-c-border-hard: #45484e;--vp-c-shadow: rgb(0 0 0 / 30%);--vp-c-control: rgb(101 117 133 / 12%);--vp-c-control-hover: rgb(101 117 133 / 18%);--vp-c-control-disabled: #363636}body{font-family:var(--font-family)}code{font-family:var(--code-font-family)}.theme-default-content h1,.theme-default-content h2,.theme-default-content h3,.theme-default-content h4,.theme-default-content h5,.theme-default-content h6{margin-top:calc(.5rem - var(--header-offset));margin-bottom:0;padding-top:calc(1rem + var(--header-offset))}.theme-default-content h1:first-child,.theme-default-content h2:first-child,.theme-default-content h3:first-child,.theme-default-content h4:first-child,.theme-default-content h5:first-child,.theme-default-content h6:first-child{margin-bottom:1rem}.theme-default-content h1:first-child+p,.theme-default-content h1:first-child+pre,.theme-default-content h1:first-child+.custom-container,.theme-default-content h2:first-child+p,.theme-default-content h2:first-child+pre,.theme-default-content h2:first-child+.custom-container,.theme-default-content h3:first-child+p,.theme-default-content h3:first-child+pre,.theme-default-content h3:first-child+.custom-container,.theme-default-content h4:first-child+p,.theme-default-content h4:first-child+pre,.theme-default-content h4:first-child+.custom-container,.theme-default-content h5:first-child+p,.theme-default-content h5:first-child+pre,.theme-default-content h5:first-child+.custom-container,.theme-default-content h6:first-child+p,.theme-default-content h6:first-child+pre,.theme-default-content h6:first-child+.custom-container{margin-top:2rem}@media (max-width: 419px){.theme-default-content h1{font-size:1.9rem}}.theme-default-content a:not(.header-anchor){text-decoration:underline}.theme-default-content img{max-width:100%}div[class*=language-]{margin:.75rem 0;transition:background-color var(--vp-t-color),color var(--vp-t-color)}@media (max-width: 419px){div[class*=language-]{--code-border-radius: 0;margin:.75rem -1.5rem}}div[class*=language-] .line.diff,div[class*=language-] .line.highlighted{transition:background-color var(--vp-t-color)}.table-of-contents .vp-badge{vertical-align:middle}.arrow{display:inline-block;vertical-align:middle;width:1em;height:1em;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(0,0,0,0.5)' d='M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'/%3E%3C/svg%3E");background-position:center;background-repeat:no-repeat;line-height:normal;transition:all .3s}[data-theme=dark] .arrow{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='rgba(255,255,255,0.5)' d='M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z'/%3E%3C/svg%3E")}.arrow.down{transform:rotate(180deg)}.arrow.right{transform:rotate(90deg)}.arrow.left{transform:rotate(-90deg)}.vp-external-link-icon:after{content:"";display:inline-block;flex-shrink:0;width:11px;height:11px;margin-top:-1px;margin-left:4px;background:var(--external-link-c-icon);-webkit-mask-image:var(--external-link-icon);mask-image:var(--external-link-icon)}.external-link-icon .external-link:after{content:"";display:inline-block;flex-shrink:0;width:11px;height:11px;margin-top:-1px;margin-left:4px;background:var(--external-link-c-icon);-webkit-mask-image:var(--external-link-icon);mask-image:var(--external-link-icon)}.external-link-icon .theme-default-content a[href*="://"]:not(.no-external-link-icon):after,.external-link-icon .theme-default-content a[target=_blank]:not(.no-external-link-icon):after{content:"";display:inline-block;flex-shrink:0;width:11px;height:11px;margin-top:-1px;margin-left:4px;background:var(--external-link-c-icon);-webkit-mask-image:var(--external-link-icon);mask-image:var(--external-link-icon)}@media screen and (max-width: 719px){.vp-hide-mobile{display:none}}.vp-comment{max-width:var(--content-width);margin:0 auto;padding:2rem 2.5rem}@media (max-width: 959px){.vp-comment{padding:2rem}}@media (max-width: 419px){.vp-comment{padding:1.5rem}}.vp-navbar .DocSearch{transition:background-color var(--vp-t-color)}.vp-navbar .search-box{vertical-align:top;flex:0 0 auto}@media screen and (max-width: 719px){.hint-container{margin-inline:-.75rem}}:root{--c-brand: #920097;--c-brand-light: #faaffd}html.dark{--c-brand: #920097;--c-brand-light: #faaffd}iframe{width:100%}
diff --git a/assets/template-DXB9rnv0.png b/assets/template-DXB9rnv0.png
new file mode 100644
index 0000000..60da74a
Binary files /dev/null and b/assets/template-DXB9rnv0.png differ
diff --git a/assets/toolbox-DbC4d27p.png b/assets/toolbox-DbC4d27p.png
new file mode 100644
index 0000000..54af85e
Binary files /dev/null and b/assets/toolbox-DbC4d27p.png differ
diff --git a/assets/uml-CgIVGHo2.png b/assets/uml-CgIVGHo2.png
new file mode 100644
index 0000000..b06dfac
Binary files /dev/null and b/assets/uml-CgIVGHo2.png differ
diff --git a/assets/welcomescreen-fw1_MuGU.png b/assets/welcomescreen-fw1_MuGU.png
new file mode 100644
index 0000000..dcbfcca
Binary files /dev/null and b/assets/welcomescreen-fw1_MuGU.png differ
diff --git a/configure/index.html b/configure/index.html
new file mode 100644
index 0000000..8e64c16
--- /dev/null
+++ b/configure/index.html
@@ -0,0 +1,240 @@
+
+
+
+
+
+
+
+
+ Configure KMP
+
+
+
+
+
+ Fleet IDE is the dedicated IDE to consider for KMP developpement with exclusive features such as better preview management
Simply download it thanks to Jetbrain ToolBox App
Use Android Studio
It is also possible to use Android Studio IDE with latest stable version koala version or above. You can do the following to prepare it to support KMP
For macOS devs only,kdoctor
command line interface (CLI) is available. It will help you to ensure that your computer is correctly configured for KMP development.
brew install kdoctor
+kdoctor
+
For your hand-on lab today, you can download the initial project by downloading KMP official sample for Android, iOS and Desktop & Web here: kmp.jetbrains.com
Select : ☑️ Android ☑️ iOS ☑️ Desktop ☑️ Web Download
the zip projectOpen it with Fleet
The gradle plugin of Kotlin Multiplatform ( KMP ) organize the code thanks to 2 essential notion of Gradle/Java :
A Module
is a set of classes and packages that form a complete whole with a build description file build.gradle
. Modules have been introduced to improve safety and to make the platform more modular. A Source sets
give us a powerful way to structure source code in our Gradle projects. A SourceSet represents a logical group of Kotlin source and resource files.
A shared library module linked to all project platforms. It contains the source code common to all your supported platforms.
This is the place where you will code all your cross platform composables.
On the sample, your first composable function App()
is already configured with a single button that display an image with a standard animation on click.
App.kt @Composable
+@Preview
+fun App ( ) {
+ MaterialTheme {
+ var showContent by remember { mutableStateOf ( false ) }
+ Column ( Modifier. fillMaxWidth ( ) , horizontalAlignment = Alignment. CenterHorizontally) {
+ Button ( onClick = { showContent = ! showContent } ) {
+ Text ( "Click me!" )
+ }
+ AnimatedVisibility ( showContent) {
+ val greeting = remember { Greeting ( ) . greet ( ) }
+ Column ( Modifier. fillMaxWidth ( ) , horizontalAlignment = Alignment. CenterHorizontally) {
+ Image ( painterResource ( Res. drawable. compose_multiplatform) , null )
+ Text ( "Compose: $ greeting " )
+ }
+ }
+ }
+ }
+}
+
One submodule per platform, linked to the common module sources. It gives the possibility to make specific implementations of functions per platform
When you need a specific implementation for Android and iOS of getPlatform() to return the platform name, KMP uses :
expect
keyword on the KMP shared library (commonMain) before functions indicating that we need a specific implementation of this functionactual
keywords on the KMP shared library specific modules (iosMain, androidMain) before functions to indicate the implementation.For exemple on this specific template, a getPlatformName
fuction is referenced on the common code and implemented specificly on each sourceset with the right platform name
platform.kt (SourceSet : commonMain) expect fun getPlatform ( ) : Platform
+
Platform.desktop.kt (SourceSet : desktopMain) actual fun getPlatformName ( ) : String = "Desktop"
+
Platform.android.kt (SourceSet : androidMain) actual fun getPlatformName ( ) : String = "Android"
+
Platform.ios.kt(SourceSet : iosMain) actual fun getPlatformName ( ) : String = "iOS"
+
More Information
On each platform sourceSet (androidMain
, desktopMain
, iosMain
, wasmJsMain
) , you can call native SDK function wrapped in Kotlin.
Ex: on Platform.ios.kt a UIDevice function is called :
UIDevice. currentDevice. systemName ( )
+
More information about platform specific functions in KMP here )
On this template a wrapper is used to use the root multiplatform composable App()
on each specific sourceSet Main
class :
onCreate
callback of an Activity
for AndroidA ViewController
class for iOS ... Then you can code and declare your composables on the App()
composable to code multiplatform. main.desktop.kt(SourceSet : desktopMain) fun main ( ) = application {
+ Window (
+ onCloseRequest = :: exitApplication,
+ title = "Quiz" ,
+ ) {
+ App ( )
+ }
+}
+
The Android app declaration with ressouces, manifest and activities A MainView
android composable is created from the App() composable.
main.android.kt (SourceSet : androidMain) @Composable fun MainView ( ) = App ( )
+
Then the composable is declared on the activity.
MainActivity.kt (androidApp) class MainActivity : ComponentActivity ( ) {
+ override fun onCreate ( savedInstanceState: Bundle? ) {
+ super . onCreate ( savedInstanceState)
+
+ setContent {
+ App ( )
+ }
+ }
+}
+
For iOSApp
project you can open the .xcodeproj with Xcode for completion, build specific configurations
It's the same principles, a swift MainViewController
that is created from the composable App()
main.ios.kt(SourceSet : iosMain) fun MainViewController ( ) = ComposeUIViewController { App ( ) }
+
Then on the .xcodeproj, ContentView.swift
convert the MainViewController
into a swiftUI view.
ContentView.swift (iosApp) .. .
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController ( context: Context) -> UIViewController {
+ MainViewControllerKt. MainViewController ( )
+ }
+
+ func updateUIViewController ( _ uiViewController: UIViewController, context: Context) { }
+}
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView ( )
+ . ignoresSafeArea ( . keyboard)
+ }
+}
+.. .
+
With those configuration you can now develop your composable in the commonMain
SourceSet and deploy your app for Android, iOS and Destop targets
To defines gradle configuration for deploying your development apps, you need to create a running configuration for fleet by creating a run.json
file in .fleet
folder.
.fleet/run.json {
+ "configurations" : [
+
+ {
+ "name" : "composeApp" ,
+ "type" : "gradle" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "tasks" : [ ":server:classes" ] ,
+ "initScripts" : {
+ "flmapper" : "ext.mapPath = { path -> path }" ,
+ "Build learning-kotlin-multiplatform-src" : "System.setProperty('org.gradle.java.compile-classpath-packaging', 'true')"
+ }
+ } ,
+ {
+ "name" : "server" ,
+ "type" : "jps-run" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "dependsOn" : [ "composeApp" ] ,
+ "mainClass" : "com.worldline.quiz.ApplicationKt" ,
+ "module" : "Quiz.server.main" ,
+ "options" : [ "-Dfile.encoding=UTF-8" ]
+ } ,
+ {
+ "name" : "iOS" ,
+ "type" : "xcode-app" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "allowParallelRun" : true ,
+ "buildTarget" : {
+ "project" : "iosApp" ,
+ "target" : "iosApp"
+ } ,
+ "configuration" : "Debug"
+ } ,
+ {
+ "name" : "wasmJs" ,
+ "type" : "gradle" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "tasks" : [ "wasmJsBrowserDevelopmentRun" ] ,
+ "args" : [ "-p" , "$PROJECT_DIR$/composeApp" ] ,
+ "initScripts" : {
+ "flmapper" : "ext.mapPath = { path -> path }"
+ }
+ } ,
+ {
+ "name" : "android" ,
+ "type" : "android-app" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "allowParallelRun" : true ,
+ "module" : "quiz.composeApp.main"
+ } ,
+ {
+ "name" : "Desktop" ,
+ "type" : "gradle" ,
+ "workingDir" : "$PROJECT_DIR$" ,
+ "tasks" : [ "desktopRun" ] ,
+ "args" : [ "-DmainClass=com.worldline.quiz.MainKt" , "--quiet" , "-p" , "$PROJECT_DIR$/composeApp" ] ,
+ "initScripts" : {
+ "flmapper" : "ext.mapPath = { path -> path }"
+ }
+ }
+ ]
+}
+
Instead, if you want to use gradle tasks , here are some examples :
./gradlew desktopRun
+./gradlew wasmJsBrowserDevelopmentRun
+
CORS issue for Web target
For the Web App, you can bypass CORS issue if you don't have a remote server with Chrome as below:
< google chrome path> --disable-web-security --user-data-dir= /Users/xxxx/Desktop/googlechrometmp http://localhost:8080/
+
A version catalog
is a list of dependencies, represented as dependency coordinates, that a user can pick from when declaring dependencies in a build script.
gradle/libs.versions.toml
+[ versions ]
+
+
+kotlin = "2.0.20"
+agp = "8.5.0"
+compose-plugin = "1.7.0-rc01"
+
+androidx-activityCompose = "1.9.2"
+navigation = "2.8.0-alpha10"
+androidx-lifecycle = "2.8.0"
+kotlinxCoroutinesCore = "1.9.0"
+
+kotlinx-coroutines = "1.8.1"
+kotlinxDatetime = "0.6.1"
+ktorVersion = "3.0.0-rc-1"
+kstore = "0.8.0"
+logback = "1.5.8"
+
+android-compileSdk = "34"
+android-minSdk = "24"
+android-targetSdk = "34"
+
+[ libraries ]
+androidx-activity-compose = { module = "androidx.activity:activity-compose" , version.ref = "androidx-activityCompose" }
+
+androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle" , name = "lifecycle-viewmodel" , version.ref = "androidx-lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle" , name = "lifecycle-runtime-compose" , version.ref = "androidx-lifecycle" }
+
+kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx" , name = "kotlinx-coroutines-swing" , version.ref = "kotlinx-coroutines" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" , version.ref = "kotlinxCoroutinesCore" }
+
+
+
+kotlin-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose" , version.ref = "navigation" }
+kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime" , version.ref = "kotlinxDatetime" }
+
+ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json" , version.ref = "ktorVersion" }
+ktor-client-core = { module = "io.ktor:ktor-client-core" , version.ref = "ktorVersion" }
+ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation" , version.ref = "ktorVersion" }
+ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp" , version.ref = "ktorVersion" }
+ktor-client-apache = { module = "io.ktor:ktor-client-apache" , version.ref = "ktorVersion" }
+ktor-client-darwin = { module = "io.ktor:ktor-client-darwin" , version.ref = "ktorVersion" }
+
+
+
+kstore = { module = "io.github.xxfast:kstore" , version.ref = "kstore" }
+kstore-file = { module = "io.github.xxfast:kstore-file" , version.ref = "kstore" }
+kstore-storage = { module = "io.github.xxfast:kstore-storage" , version.ref = "kstore" }
+
+
+
+
+
+ktor-server-core = { module = "io.ktor:ktor-server-core-jvm" , version.ref = "ktorVersion" }
+ktor-server-cio = { module = "io.ktor:ktor-server-cio" , version.ref = "ktorVersion" }
+ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation" , version.ref = "ktorVersion" }
+ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml" , version.ref = "ktorVersion" }
+ktor-server-cors = { module = "io.ktor:ktor-server-cors" , version.ref = "ktorVersion" }
+logback = { module = "ch.qos.logback:logback-classic" , version.ref = "logback" }
+
+[ plugins ]
+androidApplication = { id = "com.android.application" , version.ref = "agp" }
+androidLibrary = { id = "com.android.library" , version.ref = "agp" }
+jetbrainsCompose = { id = "org.jetbrains.compose" , version.ref = "compose-plugin" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose" , version.ref = "kotlin" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform" , version.ref = "kotlin" }
+kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization" , version.ref = "kotlin" }
+kotlinJvm = { id = "org.jetbrains.kotlin.jvm" , version.ref = "kotlin" }
+ktor = { id = "io.ktor.plugin" , version.ref = "ktorVersion" }
+
+
A logger is provided by [Ktor client library
] (https://ktor.io/docs/logging.html) for basic logs.
✅ If everything is fine, go to the next chapter →
Prev
🚀 Let's start
Next
User interface
+
+
+
diff --git a/database/index.html b/database/index.html
new file mode 100644
index 0000000..4feb33f
--- /dev/null
+++ b/database/index.html
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+
+ (Local Database)
+
+
+
+
+
+ Deprecated section
SQL delight
is for now no more compatible with the new default WASM template for WebApp application.
If you still want to use it you can revert to the old Js(IR) template.
Notice that for now this is the only Web target compatible database library for KMP
SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.
SQLDelight understands your existing SQL schema.
CREATE TABLE hockey_player (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL ,
+ number INTEGER NOT NULL
+) ;
+
It generates typesafe code for any labeled SQL statements.
Warning
Be carefull with SQL Delight , the project and his dependancies just move from com.squareup.sqldelight.*
to app.cash.sqldelight.*
Pay attention also with beta, alpha version of Android studio that could produce bugs on gradle task management for code generation of SQL Delight databases.
Refer to the multiplatform implementation of SQLDelight in official Github pages 👉 https://cashapp.github.io/sqldelight/2.0.0/multiplatform_sqlite/
plugins {
+.. .
+ id ( "app.cash.sqldelight" ) version "2.0.0"
+}
+.. .
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:runtime:2.0.0" )
+ implementation ( "app.cash.sqldelight:coroutines-extensions:2.0.0" )
+ implementation ( "org.jetbrains.kotlinx:kotlinx-datetime:0.4.1" )
+
+ }
+ }
+ val androidMain by getting {
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:android-driver:2.0.0" )
+
+ }
+ }
+ .. .
+ val iosMain by creating {
+ .. .
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:native-driver:2.0.0" )
+ }
+ }
+ val desktopMain by getting {
+ dependencies {
+ .. .
+ implementation ( "app.cash.sqldelight:sqlite-driver:2.0.0" )
+ }
+ }
+ .. .
+
Your repository handle the following cases :
If there is no network and it's the first time launch of the app : handle and error if there is no network and you have db datas : return on the flow the db data if there is network and db data are younger than 5 min : return on the flow the db data if there is network and db data are older than 5 min : retourn on the flow the network data and reset db data QuizDatabase.sq (ressources of commonMain)* CREATE TABLE update_time (
+ timestamprequest INTEGER
+) ;
+
+INSERT INTO update_time( timestamprequest) VALUES ( 0 ) ;
+
+CREATE TABLE questions (
+ id INTEGER PRIMARY KEY ,
+ label TEXT NOT NULL ,
+ correctAnswerId INTEGER NOT NULL
+ ) ;
+
+
+ CREATE TABLE answers (
+ id INTEGER NOT NULL ,
+ label TEXT NOT NULL ,
+ question_id INTEGER NOT NULL ,
+ PRIMARY KEY ( id, question_id) ,
+ FOREIGN KEY ( question_id)
+ REFERENCES questions ( id)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+ ) ;
+
+
+
+ selectUpdateTimestamp:
+ SELECT *
+ FROM update_time;
+
+ insertTimeStamp:
+ INSERT INTO update_time( timestamprequest)
+ VALUES ( :timestamp ) ;
+
+ deleteTimeStamp:
+ DELETE FROM update_time;
+
+ deleteQuestions:
+ DELETE FROM questions;
+
+ deleteAnswers:
+ DELETE FROM answers;
+
+
+ selectAllQuestionsWithAnswers:
+ SELECT *
+ FROM questions
+ INNER JOIN answers ON questions. id = answers. question_id;
+
+ insertQuestion:
+ INSERT INTO questions( id, label, correctAnswerId)
+ VALUES ( ?, ?, ?) ;
+
+ insertAnswer:
+ INSERT INTO answers( id, label, question_id)
+ VALUES ( ?, ?, ?) ;
+
+
network/QuizDB.kt (commonMain) package network
+
+
+import app. cash. sqldelight. async. coroutines. awaitAsList
+import app. cash. sqldelight. async. coroutines. awaitAsOneOrNull
+import app. cash. sqldelight. db. SqlDriver
+import com. myapplication. common. cache. Database
+import kotlinx. coroutines. CoroutineScope
+import network. data. Answer
+import network. data. Question
+
+class QuizDbDataSource ( private val sqlDriver: SqlDriver, private val coroutineScope: CoroutineScope) {
+
+ private var database= Database ( sqlDriver)
+ private var quizQueries= database. quizDatabaseQueries
+
+
+ suspend fun getUpdateTimeStamp ( ) : Long = quizQueries. selectUpdateTimestamp ( ) . awaitAsOneOrNull ( ) ? . timestamprequest ?: 0L
+
+
+ suspend fun setUpdateTimeStamp ( timeStamp: Long) {
+ quizQueries. deleteTimeStamp ( )
+ quizQueries. insertTimeStamp ( timeStamp)
+ }
+
+ suspend fun getAllQuestions ( ) : List< Question> {
+ return quizQueries. selectAllQuestionsWithAnswers ( ) . awaitAsList ( )
+
+ . groupBy { it. question_id }
+ . map { ( questionId, rowList) ->
+
+ Question (
+ id = questionId,
+ label = rowList. first ( ) . label,
+ correctAnswerId = rowList. first ( ) . correctAnswerId,
+ answers = rowList. map { answer ->
+ Answer (
+ id = answer. id_,
+ label = answer. label_
+ )
+ }
+ )
+ }
+ }
+
+
+
+ suspend fun insertQuestions ( questions: List< Question> ) {
+ quizQueries. deleteQuestions ( ) ;
+ quizQueries. deleteAnswers ( )
+ questions. forEach { question ->
+ quizQueries. insertQuestion ( question. id, question. label, question. correctAnswerId)
+ question. answers. forEach { answer ->
+ quizQueries. insertAnswer ( answer. id, answer. label, question. id)
+ }
+ }
+ }
+}
+
QuizRepository.kt package network
+
+import app. cash. sqldelight. db. SqlDriver
+import kotlinx. coroutines. CoroutineScope
+import kotlinx. coroutines. Dispatchers
+import kotlinx. coroutines. flow. MutableStateFlow
+import kotlinx. coroutines. flow. update
+import kotlinx. coroutines. launch
+import kotlinx. datetime. Clock
+import network. data. Question
+
+
+class QuizRepository ( sqlDriver: SqlDriver) {
+
+ private val mockDataSource = MockDataSource ( )
+ private val quizAPI = QuizApiDatasource ( )
+ private val coroutineScope = CoroutineScope ( Dispatchers. Main)
+ private var quizDB = QuizDbDataSource ( sqlDriver, coroutineScope)
+
+ private var _questionState= MutableStateFlow ( listOf< Question> ( ) )
+ var questionState = _questionState
+
+ init {
+ updateQuiz ( )
+ }
+
+ private suspend fun fetchQuiz ( ) : List< Question> = quizAPI. getAllQuestions ( ) . questions
+
+ private suspend fun fetchAndStoreQuiz ( ) : List< Question> {
+ val questions = fetchQuiz ( )
+ quizDB. insertQuestions ( questions)
+ quizDB. setUpdateTimeStamp ( Clock. System. now ( ) . epochSeconds)
+ return questions
+ }
+ private fun updateQuiz ( ) {
+
+
+ coroutineScope. launch {
+ _questionState. update {
+ try {
+ val lastRequest = quizDB. getUpdateTimeStamp ( )
+ if ( lastRequest == 0L || lastRequest - Clock. System. now ( ) . epochSeconds > 300000 ) {
+ fetchAndStoreQuiz ( )
+ } else {
+ quizDB. getAllQuestions ( )
+ }
+ } catch ( e: NullPointerException) {
+ fetchAndStoreQuiz ( )
+ } catch ( e: Exception) {
+ e. printStackTrace ( )
+ mockDataSource. generateDummyQuestionsList ( )
+ }
+
+ }
+ }
+ }
+}
+
✅ If everything is fine, go to the next chapter →
Prev
Preferences
+
+
+
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..0e4322c
Binary files /dev/null and b/favicon.ico differ
diff --git a/icon-192x192.png b/icon-192x192.png
new file mode 100644
index 0000000..45057eb
Binary files /dev/null and b/icon-192x192.png differ
diff --git a/icon-256x256.png b/icon-256x256.png
new file mode 100644
index 0000000..2053f3a
Binary files /dev/null and b/icon-256x256.png differ
diff --git a/icon-384x384.png b/icon-384x384.png
new file mode 100644
index 0000000..1447d28
Binary files /dev/null and b/icon-384x384.png differ
diff --git a/icon-512x512.png b/icon-512x512.png
new file mode 100644
index 0000000..6474ce2
Binary files /dev/null and b/icon-512x512.png differ
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..04ed6d3
--- /dev/null
+++ b/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+ KMP | Tech at Worldline
+
+
+
+
+
+
+
+
+
diff --git a/kotlin_logo.svg b/kotlin_logo.svg
new file mode 100644
index 0000000..6d81155
--- /dev/null
+++ b/kotlin_logo.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/logo.png b/logo.png
new file mode 100644
index 0000000..22d7a18
Binary files /dev/null and b/logo.png differ
diff --git a/logo_worldline.png b/logo_worldline.png
new file mode 100644
index 0000000..6981642
Binary files /dev/null and b/logo_worldline.png differ
diff --git a/manifest.webmanifest b/manifest.webmanifest
new file mode 100644
index 0000000..556ea3f
--- /dev/null
+++ b/manifest.webmanifest
@@ -0,0 +1,31 @@
+{
+ "theme_color": "#f635a4",
+ "background_color": "#e8d4f2",
+ "display": "standalone",
+ "scope": "/learning-kotlin-multiplatform/",
+ "start_url": "/learning-kotlin-multiplatform/index.html",
+ "name": "Kotlin training",
+ "short_name": "Kotlin training",
+ "icons": [
+ {
+ "src": "icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icon-256x256.png",
+ "sizes": "256x256",
+ "type": "image/png"
+ },
+ {
+ "src": "icon-384x384.png",
+ "sizes": "384x384",
+ "type": "image/png"
+ },
+ {
+ "src": "icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/nav/index.html b/nav/index.html
new file mode 100644
index 0000000..119de79
--- /dev/null
+++ b/nav/index.html
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+ Navigation
+
+
+
+
+
+ Compose multiplatform navigation library enable a navigation with navigation host
gradle.build.kts (module : composeApp) .. .
+ commonMain. dependencies {
+
+ plugins {
+ .. .
+ alias ( libs. plugins. kotlinSerialization)
+ }
+ commonMain. dependencies {
+ .. .
+ implementation ( libs. kotlin. navigation)
+ implementation ( libs. ktor. serialization. kotlinx. json)
+
+.. .
+
The navigation host is the configuration class that defines routes of your application.
Routes are path between all the composable screens that you will call later on your app.
For this Hands-on Lab we need 3 routes for :
At startup to the WelcomeScreen
from Welcome screen to the QuizScreen
from the final question QuizScreen
to the ScoreScreen
App.kt (SourceSet: commonMain)
+.. .
+import kotlinx. serialization. Serializable
+
+val questions = listOf (
+ Question (
+ 1 ,
+ "Android is a great platform ?" ,
+ 1 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ ) ,
+ Question (
+ 1 ,
+ "Android is a bad platform ?" ,
+ 2 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ )
+)
+
+@Serializable
+object WelcomeRoute
+
+@Serializable
+object QuizRoute
+
+@Serializable
+data class ScoreRoute ( val score: Int, val questionSize: Int)
+
+@Composable
+fun App (
+ navController: NavHostController = rememberNavController ( )
+) {
+
+ MaterialTheme {
+ NavHost (
+ navController = navController,
+ startDestination = WelcomeRoute,
+ ) {
+ composable< WelcomeRoute> {
+ welcomeScreen (
+ onStartButtonPushed = {
+ navController. navigate ( route = QuizRoute)
+ }
+ )
+ }
+ composable< QuizRoute> {
+ questionScreen (
+ questions = questions,
+
+ onFinishButtonPushed = {
+ score: Int, questionSize: Int -> navController. navigate ( route = ScoreRoute ( score, questionSize) )
+ }
+ )
+ }
+ composable< ScoreRoute> { backStackEntry ->
+ val scoreRoute: ScoreRoute = backStackEntry. toRoute< ScoreRoute> ( )
+ scoreScreen (
+ score = scoreRoute. score,
+ total = scoreRoute. questionSize,
+ onResetButtonPushed = {
+ navController. navigate ( route = QuizRoute)
+ }
+ )
+ }
+ }
+ }
+}
+
Warning
As you can see all composables now take as parameter a navigator. It will be needed to navigate with routes between screens.
for example, the WelcomeScreen
composable is now declared as follows :
@Composable ( )
+fun welcomeScreen ( navigator: Navigator) {
+ .. .
+
+
Use onStartButtonPushed
declared on screen instantiation in the NavHost
on welcome screen buttons click
WelcomeScreen.kt (SourceSet: commonMain) fun welcomeScreen ( onStartButtonPushed: ( ) -> Unit) {
+.. .
+
+ Button (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ onClick = { onStartButtonPushed ( ) }
+ ) {
+.. .
+
The same can be done for other screens
QuestionScreen.kt (commonMain)
fun questionScreen ( questions: List< Question> , onFinishButtonPushed: ( Int, Int) -> Unit) {
+..
+Button (
+ modifier = Modifier. padding ( bottom = 20 . dp) ,
+ onClick = {
+
+ if ( getPlatform ( ) . name == "WASM" ) {
+ onSaveStatQuestion (
+ questions[ questionProgress] . id,
+ questions[ questionProgress] . label,
+ selectedAnswer,
+ questions[ questionProgress] . correctAnswerId,
+ questions[ questionProgress] . answers[ selectedAnswer. toInt ( ) - 1 ] . label
+ )
+ }
+
+ if ( selectedAnswer == questions[ questionProgress] . correctAnswerId) {
+ score++
+ }
+ if ( questionProgress < questions. size - 1 ) {
+ questionProgress++
+ selectedAnswer = 1
+ } else {
+ onFinishButtonPushed ( score, questions. size)
+ }
+ }
+}
+.. .
+
ScoreScreen.kt (SourceSet : commonMain)
+fun scoreScreen ( score: Int, total: Int, onResetButtonPushed: ( ) -> Unit) {
+.. .
+ Button (
+ modifier = Modifier. padding ( all = 20 . dp) ,
+ onClick = {
+ onResetButtonPushed ( )
+ }
+ )
+.. .
+
Sources
The full solution for this section is availabe here
✅ If everything is fine, congrats, you've just finish this codelab. You can now experiment your kotlin skills eveywhere !
Prev
User interface
Next
Ressources
+
+
+
diff --git a/network/index.html b/network/index.html
new file mode 100644
index 0000000..5a921c1
--- /dev/null
+++ b/network/index.html
@@ -0,0 +1,319 @@
+
+
+
+
+
+
+
+
+ Connectivity
+
+
+
+
+
+ Let's connect our Quiz app to internet.
For now, we will request a simple plain text json file hosted on this repo that will simulate a REST API call to be able to use our Ktor client.
The request & answers details are specified below :
Request POST
+content-type: text/plain
+url: https://github.com/worldline/learning-kotlin-multiplatform/raw/main/quiz.json
+
Answer code:200
+body:
+{
+ "questions" : [
+ {
+ "id" :1,
+ "label" : "You can create an emulator to simulate the configuration of a particular type of Android device using a tool like" ,
+ "correct_answer_id" :3,
+ "answers" :[
+ { "id" :1, "label" : "Theme Editor" } ,
+ { "id" :2, "label" : "Android SDK Manager" } ,
+ { "id" :3, "label" : "AVD Manager" } ,
+ { "id" :4, "label" : "Virtual Editor" }
+ ]
+ } ,
+ {
+ "id" :2,
+ "label" : "What parameter specifies the Android API level that Gradle should use to compile your app?" ,
+ "correct_answer_id" :2,
+ "answers" :[
+ { "id" :1, "label" : "minSdkVersion" } ,
+ { "id" :2, "label" : "compileSdkVersion" } ,
+ { "id" :3, "label" : "targetSdkVersion" } ,
+ { "id" :4, "label" : "testSdkVersion" }
+ ]
+ } ,
+ ]
+}
+
To not overcomplexify the app, let's assume that :
the QuizAPI provided by Ktor (cf below) is our data source the repository will use a state flow that emit the API answer once at application startup Ktor includes a multiplatform asynchronous HTTP client, which allows you to make requests and handle responses, extend its functionality with plugins, such as authentication and JSON deserialization.
Shared sources need it to use ktor library on your code
build.gradle.kts (composeApp) plugins {
+.. .
+ alias ( libs. plugins. kotlinSerialization)
+}
+
+.. .
+ sourceSets {
+ val desktopMain by getting
+ commonMain. dependencies {
+ .. .
+ implementation ( libs. kotlinx. datetime)
+ implementation ( libs. ktor. client. core)
+ implementation ( libs. ktor. client. content. negotiation)
+ implementation ( libs. ktor. serialization. kotlinx. json)
+
+ }
+ androidMain. dependencies {
+ .. .
+ implementation ( libs. ktor. client. okhttp)
+ }
+ desktopMain. dependencies {
+ .. .
+ implementation ( libs. ktor. client. apache)
+
+ }
+ iosMain. dependencies {
+ implementation ( libs. ktor. client. darwin)
+ }
+
+ }
+.. .
+
+
You need to enable internet on Android otherwise you will not be able to use ktor client
AndroidManifest.xml( androidMain) < uses-permission android: name= " android.permission.INTERNET" />
+ < uses-permission android: name= " android.permission.ACCESS_NETWORK_STATE" />
+
QuizApiDataSource.kt (SourceSet : commonMain) import com. worldline. quiz. data. dataclass. Quiz
+
+val globalHttpClient = HttpClient {
+ engine {
+
+ }
+
+ install ( ContentNegotiation) {
+ json (
+ contentType = ContentType. Text. Plain,
+ json = Json {
+ ignoreUnknownKeys = true
+ useAlternativeNames = false
+ } )
+ }
+}
+
+class QuizApiDatasource {
+ private val httpClient = globalHttpClient
+ suspend fun getAllQuestions ( ) : Quiz {
+ return httpClient. get ( "https://raw.githubusercontent.com/worldline/learning-kotlin-multiplatform/main/quiz.json" ) . body ( )
+ }
+}
+
+
Ktor need it to transform the json string into your dataclasses
Answer.kt (module : commonMain) @kotlinx . serialization. Serializable
+data class Answer ( val id: Int, val label: String )
+
Question.kt (SourceSet : commonMain) import kotlinx. serialization. SerialInfo
+import kotlinx. serialization. SerialName
+
+@kotlinx . serialization. Serializable
+data class Question ( val id: Int, val label: String, @SerialName ( "correct_answer_id" ) val correctAnswerId: Int, val answers: List< Answer> )
+
Quiz.kt (SourceSet : commonMain) @kotlinx . serialization. Serializable
+data class Quiz ( var questions: List< Question> )
+
QuizRepository.kt (module : commonMain) class QuizRepository {
+
+ private val mockDataSource = MockDataSource ( )
+ private val quizApiDatasource = QuizApiDatasource ( )
+
+ private suspend fun fetchQuiz ( ) : List< Question> = quizApiDatasource. getAllQuestions ( ) . questions
+
+ suspend fun updateQuiz ( ) : List< Question> {
+ try {
+ return fetchQuiz ( )
+ } catch ( e: Exception) {
+ e. printStackTrace ( )
+ return mockDataSource. generateDummyQuestionsList ( )
+ }
+ }
+}
+
Sources
The full sources can be retrieved here
Warning
You can create the server module from IntelliJ community or ultimate thanks to a template.
The module tree is as follow
build.gradle.kts plugins {
+ alias ( libs. plugins. kotlinJvm)
+ alias ( libs. plugins. ktor)
+ alias ( libs. plugins. kotlinSerialization)
+ application
+}
+
+group = "com.worldline.quiz"
+version = "1.0.0"
+
+application {
+ mainClass. set ( "com.worldline.quiz.ApplicationKt" )
+ applicationDefaultJvmArgs = listOf ( "-Dio.ktor.development= ${ extra[ "io.ktor.development" ] ?: "false" } " )
+}
+
+dependencies {
+ implementation ( libs. logback)
+ implementation ( libs. ktor. server. core)
+ implementation ( libs. ktor. server. cio)
+ implementation ( libs. ktor. serialization. kotlinx. json)
+ implementation ( libs. ktor. server. content. negotiation)
+ implementation ( libs. ktor. server. cors)
+ implementation ( libs. ktor. server. config. yaml)
+}
+
+ktor {
+ fatJar {
+ archiveFileName. set ( "fat.jar" )
+ }
+ docker {
+ externalRegistry. set (
+ io. ktor. plugin. features. DockerImageRegistry. dockerHub (
+ appName = provider { "ktor-quiz" } ,
+ username = providers. environmentVariable ( "KTOR_IMAGE_REGISTRY_USERNAME" ) ,
+ password = providers. environmentVariable ( "KTOR_IMAGE_REGISTRY_PASSWORD" )
+ )
+ )
+ }
+}
+
Application.kt ffun main ( args: Array< String> ) {
+ io. ktor. server. cio. EngineMain. main ( args)
+}
+
+
+fun Application. module ( ) {
+
+ install ( CORS) {
+ allowMethod ( HttpMethod. Options)
+ allowMethod ( HttpMethod. Post)
+ allowMethod ( HttpMethod. Get)
+ allowHeader ( HttpHeaders. AccessControlAllowOrigin)
+ allowHeader ( HttpHeaders. ContentType)
+ anyHost ( )
+ }
+
+ install ( ContentNegotiation) {
+ json ( )
+ }
+ configureRouting ( )
+}
+
Routing.kt fun Application. configureRouting ( ) {
+
+ routing {
+ get ( "/quiz" ) {
+ call. respond ( generateQuiz ( ) )
+ }
+ staticResources ( "/" , "static" )
+ }
+}
+
+fun generateQuiz ( ) : Quiz {
+ val quizQuestions = mutableListOf< Question> ( )
+
+ val questions = listOf (
+ "What is the primary goal of Kotlin Multiplatform?" ,
+ "How does Kotlin Multiplatform facilitate code sharing between platforms?" ,
+ "Which platforms does Kotlin Multiplatform support?" ,
+ "What is a common use case for Kotlin Multiplatform?" ,
+ "Which naming of KMP is deprecated?" ,
+ "How does Kotlin Multiplatform handle platform-specific implementations?" ,
+ "At which Google I/O, Google announced first-class support for Kotlin on Android?" ,
+ "What is the name of the Kotlin mascot?" ,
+ "The international yearly Kotlin conference is called..." ,
+ "Where will be located the next international yearly Kotlin conference?"
+ )
+
+ val answers = listOf (
+ listOf (
+ "To share code between multiple platforms" ,
+ "To exclusively compile code to JavaScript" ,
+ "To build only Android applications" ,
+ "To create iOS-only applications"
+ ) ,
+ listOf (
+ "By sharing business logic and adapting UI" ,
+ "By writing separate code for each platform" ,
+ "By using only Java libraries" ,
+ "By using code translation tools"
+ ) ,
+ listOf (
+ "Android, iOS, desktop and web" ,
+ "Only Android" ,
+ "Only iOS" ,
+ "Only web applications"
+ ) ,
+ listOf (
+ "Developing a cross-platform app" ,
+ "Building a desktop-only application" ,
+ "Creating a server-side application" ,
+ "Writing a standalone mobile app"
+ ) ,
+ listOf (
+ "Kotlin Multiplatform Mobile (KMM)" ,
+ "Hadi Multiplatform" ,
+ "Jetpack multiplatform" ,
+ "Kodee multiplatform"
+ ) ,
+ listOf (
+ "Through expect and actual declarations" ,
+ "By automatically translating code" ,
+ "By restricting to a single platform" ,
+ "By excluding platform-specific features"
+ ) ,
+ listOf (
+ "2017" ,
+ "2016" ,
+ "2014" ,
+ "2020"
+ ) ,
+ listOf (
+ "Kodee" ,
+ "Hadee" ,
+ "Kotlinee" ,
+ "Kotee"
+ ) ,
+ listOf (
+ "KotlinConf" ,
+ "KodeeConf" ,
+ "KConf" ,
+ "KotlinKonf"
+ ) ,
+ listOf (
+ "Copenhagen, Denmark" ,
+ "Amsterdam, Netherlands" ,
+ "Tokyo, Japan" ,
+ "Lille, France"
+ )
+ )
+
+ for ( i in questions. indices) {
+ val shuffledAnswers = answers[ i] . shuffled ( Random. Default)
+ val correctAnswerId = shuffledAnswers. indexOfFirst { it == answers[ i] [ 0 ] } + 1
+ val question =
+ Question ( i + 1L , questions[ i] , correctAnswerId. toLong ( ) , shuffledAnswers. mapIndexed { index, answer ->
+ Answer ( index + 1L , answer)
+ } )
+ quizQuestions. add ( question)
+ }
+
+ return Quiz ( quizQuestions)
+}
+
+
Other libs
If you want well-known retrofit style lib, you can use KtorFit to separate endpoint declaration from httpclient configuration
Also for better image loading from the internet with cache, you can use the following third-party Compose Multiplatform libraries
An that's it, you quiz have now a remote list of questions.
✅ If everything is fine, go to the next chapter →
Prev
Architecture
Next
Preferences
+
+
+
diff --git a/overview/index.html b/overview/index.html
new file mode 100644
index 0000000..2276ac0
--- /dev/null
+++ b/overview/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+ 🚀 Let's start
+
+
+
+
+
+ Basic knowledge of kotlin development (nullability,inline & lambda functions mainly). For more information, please refer to the Worldline kotlin training Fleet IDE is the dedicated IDE to consider for KMP developpement with exclusive features such as better preview managementA good connectivity Advanced installation
For more information about your DEV environment and installs please have a look to jetbrain related docs
Consider also installing Jetbrain ToolBox for managing multiple versions ( Beta , Alpha , stable) of Android Studio or Fleet
Definition:
FP uses an approach to software development that uses pure functions to create maintainable software It uses immutable functions and avoids shared states. It is in contrast to object-oriented programming languages which uses mutable states It focuses on results and not process, while the iterations like for loops are not allowed Advantages: Problems are easy Keeps concurrency safe
let numbers = [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 ]
+
+
+let oddNumbers = numbers. filter { $0 % 2 != 0 }
+
+
+let squaredNumbers = oddNumbers. map { $0 * $0 }
+
+
+let sumOfSquares = squaredNumbers. reduce ( 0 , + )
+
+print ( "The sum of squares of odd numbers is \( sumOfSquares ) " )
+
+
Filter : filters array to include only odd numbers Map : squares the numbers Reduce : sums up the squared numbers
This example demonstrates the core principles of functional programming: using functions as first-class citizens to transform and compose data in a clear and concise way.
See the roadmap 2024 on official Jetbrain blog
We will create a simple quiz application that provides :
a Startup screen explaining rules of the game a Quiz screen looping on single choices questions a final scoring screen. The app can be deployed on Android , iOS and jvm Desktop. We will use not only a common library but composable views shared for all platforms Here are expected screens at the end of this Hands-on Lab.
Generate composables based on designs
You can generate composables based on designs on Figma thanks to the plugin Google Relay . A dedicated section on android developer documentation describe all the steps here
Prev
Home
Next
Configure KMP
+
+
+
diff --git a/preferences/index.html b/preferences/index.html
new file mode 100644
index 0000000..62fc856
--- /dev/null
+++ b/preferences/index.html
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+ Preferences
+
+
+
+
+
+ Kstore is a tiny Kotlin multiplatform library that assists in saving and restoring objects to and from disk using kotlinx.coroutines, kotlinx.serialization and kotlinx.io. Inspired by RxStore
Add kstore dependency to your project for each target platform
build.gradle.kts (composeMain) commonMain. dependencies {
+ .. .
+ implementation ( libs. kstore)
+ }
+ androidMain. dependencies {
+ .. .
+ implementation ( libs. kstore. file)
+ }
+ desktopMain. dependencies {
+ .. .
+ implementation ( libs. kstore. file)
+ }
+ iosMain. dependencies {
+ implementation ( libs. kstore. file)
+ }
+ wasmJsMain. dependencies {
+ implementation ( libs. kstore. storage)
+ }
+ .. .
+
Define the native call to get the kstore instance
platform.kt (commonMain) expect fun getKStore ( ) : KStore< Quiz> ?
+
Define each platform call to get the kstore instance for Android, iOS, Web, Desktop
platform.kt (androidMain) actual fun getKStore ( ) : KStore< Quiz> ? {
+ return storeOf ( QuizApp. context ( ) . dataDir. path. plus ( "/quiz.json" ) . toPath ( ) )
+ }
+
Also Android needs context to instanciate the kstore. Without injection library, you can use an App context singleton.
QuizApp.kt (androidMain) class QuizApp : Application ( ) {
+ init {
+ app = this
+ }
+
+ companion object {
+ private lateinit var app: QuizApp
+ fun context ( ) : Context = app. applicationContext
+ }
+}
+
Add the QuizApp to the AndroidManifest.xml
AndroidManifest.xml (androidMain) ...
+ <application
+ android:name=".QuizApp"
+...
+
platform.kt (iosMain) @OptIn ( ExperimentalKStoreApi:: class )
+ actual fun getKStore ( ) : KStore< Quiz> ? {
+ return NSFileManager. defaultManager. DocumentDirectory? . relativePath? . plus ( "/quiz.json" ) ? . toPath ( ) ? . let {
+ storeOf (
+ file= it
+ )
+ }
+ }
+
platform.kt (wasmJsMain) actual fun getKStore ( ) : KStore< Quiz> ? {
+ return storeOf ( key = "kstore_quiz" )
+ }
+
+
platform.kt (desktopMain) actual fun getKStore ( ) : KStore< Quiz> ? {
+ return storeOf ( "quiz.json" . toPath ( ) )
+ }
+
+
Upgrade the Quiz object with an update timestamp
Quiz.kt (commonMain) @Serializable
+data class Quiz ( var questions: List< Question> , val updateTime: Long= 0L )
+
Create a QuizKStoreDataSource class to store the kstore data
QuizKStoreDataSource.kts (commonMain) class QuizKStoreDataSource {
+ private val kStoreQuiz: KStore< Quiz> ? = getKStore ( )
+ suspend fun getUpdateTimeStamp ( ) : Long = kStoreQuiz? . get ( ) ? . updateTime ?: 0L
+
+ suspend fun setUpdateTimeStamp ( timeStamp: Long) {
+ kStoreQuiz? . update { quiz: Quiz? ->
+ quiz? . copy ( updateTime = timeStamp)
+ }
+ }
+
+ suspend fun getAllQuestions ( ) : List< Question> {
+ return kStoreQuiz? . get ( ) ? . questions ?: emptyList ( )
+ }
+
+ suspend fun insertQuestions ( newQuestions: List< Question> ) {
+ kStoreQuiz? . update { quiz: Quiz? ->
+ quiz? . copy ( questions = newQuestions)
+ }
+ }
+
+ suspend fun resetQuizKstore ( ) {
+ kStoreQuiz? . delete ( )
+ kStoreQuiz? . set ( Quiz ( emptyList ( ) , 0L ) )
+ }
+}
+
Update the QuizRepository class to use the kstore
QuizRepository.kts (commonMain) class QuizRepository {
+ private val mockDataSource = MockDataSource ( )
+ private val quizApiDatasource = QuizApiDatasource ( )
+ private var quizKStoreDataSource = QuizKStoreDataSource ( )
+
+ private suspend fun fetchQuiz ( ) : List< Question> = quizApiDatasource. getAllQuestions ( ) . questions
+
+ private suspend fun fetchAndStoreQuiz ( ) : List< Question> {
+ quizKStoreDataSource. resetQuizKstore ( )
+ val questions = fetchQuiz ( )
+ quizKStoreDataSource. insertQuestions ( questions)
+ quizKStoreDataSource. setUpdateTimeStamp ( Clock. System. now ( ) . epochSeconds)
+ return questions
+ }
+
+ suspend fun updateQuiz ( ) : List< Question> {
+ try {
+ val lastRequest = quizKStoreDataSource. getUpdateTimeStamp ( )
+ return if ( lastRequest == 0L || lastRequest - Clock. System. now ( ) . epochSeconds > 300000 ) {
+ fetchAndStoreQuiz ( )
+ } else {
+ quizKStoreDataSource. getAllQuestions ( )
+ }
+ } catch ( e: NullPointerException) {
+ return fetchAndStoreQuiz ( )
+ } catch ( e: Exception) {
+ e. printStackTrace ( )
+ return mockDataSource. generateDummyQuestionsList ( )
+ }
+ }
+
+}
+
Sources
The full sources can be retrieved here
VIDEO Prev
Connectivity
Next
(Local Database)
+
+
+
diff --git a/res/index.html b/res/index.html
new file mode 100644
index 0000000..c8afc2f
--- /dev/null
+++ b/res/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ Ressources
+
+
+
+
+
+ For common code, store your resource files in the resources directory of the commonMain source set. For platform-specific code, store your resource files in the resources directory of the corresponding source set. Jetbrain release his experimental API painterResource
from org.jetbrains.compose.resource
package
@ExperimentalResourceApi
+@Composable
+public fun painterResource (
+ res: String
+) : Painter
+
Return a Painter from the given resource path. Can load either a BitmapPainter for rasterized images (.png, .jpg) or a VectorPainter for XML Vector Drawables (.xml).XML Vector Drawables have the same format as for Android (https://developer.android.com/reference/android/graphics/drawable/VectorDrawable) except that external references to Android resources are not supported.Note that XML Vector Drawables are not supported for Web target currently. To make your resources accessible from the resource library, use the following configuration in your build.gradle.kts file:
android {
+
+ sourceSets[ "main" ] . resources. srcDirs ( "src/commonMain/resources" )
+}
+
The Compose Multiplatform Gradle plugin handles resource deployment. The plugin stores resource files in the compose-resources directory of the resulting application bundle.
val commonMain by getting {
+ dependencies {
+
+ @OptIn ( org. jetbrains. compose. ExperimentalComposeLibrary:: class )
+ implementation ( compose. components. resources)
+ }
+}
+
Nothing to do for desktop App
Image (
+ painterResource ( "compose-multiplatform.xml" ) ,
+ null
+)
+
For more ressource management possibilities for font and String management, you can use a third party lib :
@OptIn ( ExperimentalResourceApi:: class )
+@Composable
+fun App ( ) {
+ var text: String? by remember { mutableStateOf ( null ) }
+
+ LaunchedEffect ( Unit) {
+ text = String ( resource ( "welcome.txt" ) . readBytes ( ) )
+ }
+
+ text? . let {
+ Text ( it)
+ }
+}
+
✅ If everything is fine, congrats, you've just finish this codelab. You can now experiment your kotlin skills eveywhere !
Prev
Navigation
Next
Architecture
+
+
+
diff --git a/robots.txt b/robots.txt
new file mode 100644
index 0000000..36cfb01
--- /dev/null
+++ b/robots.txt
@@ -0,0 +1,3 @@
+
+User-agent:*
+Disallow:
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..e7884a6
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+
+
+ User interface
+
+
+
+
+
+ Compose Multiplatform simplifies and accelerates UI development for Desktop and Web applications, and allows extensive UI code sharing between Android, iOS, Desktop and Web. It's a modern toolkit for building native UI. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs. It is based on Android Jetpack Compose declarative UI approach ( which is similar also to SwiftUI for iOS )
Composables are UI components that can be simply declared with code as functions, properties (such as text color, fonts...) as function parameters and subviews are declared on function declaration.
An @Composable
annotation come always before the composable function. Properties of size, behaviors of components can be set thanks to Modifiers
. It permit to decorate and augent composables You can align components with containers composables such as Column
(Vertically), Box
, Row
(Horizontally) Also you can preview composables with the annotation @Preview
before the composable annotation. Example: 2 texts vertically aligned that fit all the width of the screen.
@Composable
+fun App ( ) {
+ MaterialTheme {
+ Column ( Modifier. fillMaxWidth ( ) ) {
+ Text ( "My Text1" , color = Color. Blue)
+ Text ( text = "My Text2" )
+ }
+ }
+}
+
You can now create your first view. For the Quiz we need a welcome screen displaying a Card centered with a button inside to start the quiz It is simply compose of the following composables :
a Card rounded shape container
a Text
a Button
Create a new composable WelcomeScreen.kt
on commonMain module
Make sure that the App() composable is using it has below
@Composable
+fun App ( ) {
+ MaterialTheme {
+ welcomeScreen ( )
+ }
+}
+
Run you first view on all platforms , it should work. WelcomeScreen.kt (SourseSet : commonMain) package com. worldline. quiz
+
+import androidx. compose. foundation. layout. Box
+import androidx. compose. foundation. layout. Column
+import androidx. compose. foundation. layout. fillMaxHeight
+import androidx. compose. foundation. layout. fillMaxWidth
+import androidx. compose. foundation. layout. padding
+import androidx. compose. foundation. shape. RoundedCornerShape
+import androidx. compose. material. Button
+import androidx. compose. material. Card
+import androidx. compose. material. Text
+import androidx. compose. runtime. Composable
+import androidx. compose. ui. Alignment
+import androidx. compose. ui. Modifier
+import androidx. compose. ui. unit. dp
+import androidx. compose. ui. unit. sp
+
+@Composable
+fun welcomeScreen ( ) {
+
+ Box (
+ contentAlignment = Alignment. Center,
+ modifier = Modifier. fillMaxWidth ( ) . fillMaxHeight ( )
+ ) {
+ Card (
+ shape = RoundedCornerShape ( 8 . dp) ,
+ modifier = Modifier. padding ( 10 . dp) ,
+ ) {
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+
+
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+ Text (
+ text = "Quiz" ,
+ fontSize = 30 . sp,
+ modifier = Modifier. padding ( all = 10 . dp)
+ )
+ Text (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ text = "A simple Quiz to discovers KMP and compose." ,
+ )
+ Button (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ onClick = { }
+
+ ) {
+ Text ( "Start the Quiz" )
+ }
+ }
+ }
+ }
+ }
+}
+
The second view will be quite similar but able de show final scores
Create a new composable ScoreScreen.kt
on commonMain module Make sure that the App() composable is using it has below The composable will have a String
value as parameter @Composable
+fun App ( ) {
+ MaterialTheme {
+ scoreScreen ( "10/20" )
+ }
+}
+
Run you first view on all platforms , it should work. ScoreScreen.kt (SourseSet : commonMain) package com. worldline. quiz
+
+import androidx. compose. foundation. layout. Box
+import androidx. compose. foundation. layout. Column
+import androidx. compose. foundation. layout. fillMaxHeight
+import androidx. compose. foundation. layout. fillMaxWidth
+import androidx. compose. foundation. layout. padding
+import androidx. compose. foundation. shape. RoundedCornerShape
+import androidx. compose. material. Button
+import androidx. compose. material. Card
+import androidx. compose. material. Icon
+import androidx. compose. material. Text
+import androidx. compose. material. icons. Icons
+import androidx. compose. material. icons. filled. Refresh
+import androidx. compose. runtime. Composable
+import androidx. compose. ui. Alignment
+import androidx. compose. ui. Modifier
+import androidx. compose. ui. graphics. Color
+import androidx. compose. ui. unit. dp
+import androidx. compose. ui. unit. sp
+
+@Composable
+fun scoreScreen ( score: String) {
+ Box (
+ contentAlignment = Alignment. Center,
+ modifier = Modifier. fillMaxWidth ( ) . fillMaxHeight ( )
+ ) {
+ Card (
+ shape = RoundedCornerShape ( 8 . dp) ,
+ modifier = Modifier. padding ( 10 . dp) ,
+ backgroundColor = Color. Green
+ ) {
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+ Column ( horizontalAlignment = Alignment. CenterHorizontally) {
+ Text (
+ fontSize = 15 . sp,
+ text = "score" ,
+ )
+ Text (
+ fontSize = 30 . sp,
+ text = score,
+ )
+ Button (
+ modifier = Modifier. padding ( all = 20 . dp) ,
+ onClick = {
+ }
+ ) {
+ Icon ( Icons. Filled. Refresh, contentDescription = "Localized description" )
+ Text ( text = "Retake the Quiz" )
+ }
+ }
+ }
+ }
+ }
+}
+
We can create classes on the package network.data
Answer.kt (commonMain) data class Answer ( val id: Int, val label: String )
+
Question.kt (commonMain) data class Question ( val id: Int, val label: String, val correctAnswerId: Int, val answers: List< Answer> )
+
Quiz.kt (commonMain) data class Quiz ( var questions: List< Question> )
+
Now we can make a composable with interactions.
The screen is composed of :
The question label in a Card
Single choice answer component with RadioButton
A Button
to submit the answer A LinearProgressIndicator
indicating the quiz progress After creating the UI view, we can pass to this composable the list of questions. When the App
composable will create questionScreen()
composable we will generate mock questions data for now to generate the list of questions.
All views of question will be one unique composable that updates with the correct question/answers data each time we are clicking on the next
button.
We use MutableState
value for that. It permit to keep data value and recompose the view when the data is changed. It's exactly what we need for our quiz page :
Keep the value of the question position on the list Keep the value of the answer selected by the user each time he switch between RadioButtons Keep the score to get the final one at the end of the list. Here is an example of MutableState
value declaration
var questionProgress by remember { mutableStateOf ( 0 ) }
+ .. .
+
You can declare the 2 other MutableState values and after use it on your composable ensuring that on the button click questionProgress
is incrementing so the question and his answers can change on the view.
QuestionScreen.kt (SourceSet : commonMain) package com. worldline. quiz
+
+import androidx. compose. foundation. layout. Arrangement
+import androidx. compose. foundation. layout. Column
+import androidx. compose. foundation. layout. Row
+import androidx. compose. foundation. layout. fillMaxHeight
+import androidx. compose. foundation. layout. fillMaxWidth
+import androidx. compose. foundation. layout. height
+import androidx. compose. foundation. layout. padding
+import androidx. compose. foundation. selection. selectableGroup
+import androidx. compose. foundation. shape. RoundedCornerShape
+import androidx. compose. material. Button
+import androidx. compose. material. Card
+import androidx. compose. material. Icon
+import androidx. compose. material. LinearProgressIndicator
+import androidx. compose. material. RadioButton
+import androidx. compose. material. Text
+import androidx. compose. material. icons. Icons
+import androidx. compose. material. icons. automirrored. filled. ArrowForward
+import androidx. compose. material. icons. filled. Done
+import androidx. compose. runtime. Composable
+import androidx. compose. runtime. getValue
+import androidx. compose. runtime. mutableStateOf
+import androidx. compose. runtime. remember
+import androidx. compose. runtime. setValue
+import androidx. compose. ui. Alignment
+import androidx. compose. ui. Modifier
+import androidx. compose. ui. graphics. vector. ImageVector
+import androidx. compose. ui. text. style. TextAlign
+import androidx. compose. ui. unit. dp
+import androidx. compose. ui. unit. sp
+import network. data. Question
+
+@Composable
+fun questionScreen ( questions: List< Question> ) {
+
+ var questionProgress by remember { mutableStateOf ( 0 ) }
+ var selectedAnswer by remember { mutableStateOf ( 1 ) }
+ var score by remember { mutableStateOf ( 0 ) }
+
+ Column (
+ modifier = Modifier. fillMaxWidth ( ) . fillMaxHeight ( ) ,
+ verticalArrangement = Arrangement. Center,
+ horizontalAlignment = Alignment. CenterHorizontally
+ ) {
+ Card (
+ shape = RoundedCornerShape ( 5 . dp) ,
+ modifier = Modifier. padding ( 60 . dp)
+ ) {
+ Column (
+ horizontalAlignment = Alignment. CenterHorizontally,
+ modifier = Modifier. padding ( horizontal = 10 . dp)
+ ) {
+ Text (
+ modifier = Modifier. padding ( all = 10 . dp) ,
+ text = questions[ questionProgress] . label,
+ fontSize = 25 . sp,
+ textAlign = TextAlign. Center
+ )
+ }
+ }
+ Column ( modifier = Modifier. selectableGroup ( ) ) {
+ questions[ questionProgress] . answers. forEach { answer ->
+ Row (
+ modifier = Modifier. padding ( horizontal = 16 . dp) ,
+ verticalAlignment = Alignment. CenterVertically
+ ) {
+ RadioButton (
+ modifier = Modifier. padding ( end = 16 . dp) ,
+ selected = ( selectedAnswer == answer. id) ,
+ onClick = { selectedAnswer = answer. id } ,
+ )
+ Text ( text = answer. label)
+ }
+ }
+ }
+ Column ( modifier = Modifier. fillMaxHeight ( ) , horizontalAlignment = Alignment. CenterHorizontally, verticalArrangement = Arrangement. Bottom) {
+ Button (
+ modifier = Modifier. padding ( bottom = 20 . dp) ,
+ onClick = {
+ if ( selectedAnswer == questions[ questionProgress] . correctAnswerId) {
+ score++
+ }
+ if ( questionProgress < questions. size - 1 ) {
+ questionProgress++
+ selectedAnswer = 1
+ } else {
+
+ }
+ }
+ ) {
+ if ( questionProgress < questions. size - 1 ) nextOrDoneButton ( Icons. AutoMirrored. Filled. ArrowForward, "Next" )
+ else nextOrDoneButton ( Icons. Filled. Done, "Done" )
+ }
+ LinearProgressIndicator ( modifier = Modifier. fillMaxWidth ( ) . height ( 20 . dp) , progress = questionProgress. div ( questions. size. toFloat ( ) ) . plus ( 1 . div ( questions. size. toFloat ( ) ) ) )
+ }
+ }
+}
+
+@Composable
+fun nextOrDoneButton ( iv: ImageVector, label: String) {
+ Icon (
+ iv,
+ contentDescription = "Localized description" ,
+ Modifier. padding ( end = 15 . dp)
+ )
+ Text ( label)
+}
+
App.kt (SourceSet : commonMain) @Composable
+fun App ( ) {
+ MaterialTheme {
+ val questions = listOf (
+ Question (
+ 1 ,
+ "Android is a great platform ?" ,
+ 1 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ ) ,
+ Question (
+ 1 ,
+ "Android is a bad platform ?" ,
+ 2 ,
+ listOf ( Answer ( 1 , "YES" ) , Answer ( 2 , "NO" ) )
+ )
+ )
+ questionScreen ( questions)
+ }
+}
+
Your Quiz have now all his composable screens made. Let's connect it to the Internet
✅ If everything is fine, go to the next chapter →
Sources
The full solution for this section is availabe here
Prev
Configure KMP
Next
Navigation
+
+
+