<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Erik on Medium]]></title>
        <description><![CDATA[Stories by Erik on Medium]]></description>
        <link>https://medium.com/@lazyloadin?source=rss-cc8ca1ed0899------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/0*5Q6Q4KN69P0Y8sg9</url>
            <title>Stories by Erik on Medium</title>
            <link>https://medium.com/@lazyloadin?source=rss-cc8ca1ed0899------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 12:57:17 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@lazyloadin/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[How I built my tech startup as a solo developer]]></title>
            <link>https://medium.com/dreamwod-tech/how-i-built-my-tech-startup-as-a-solo-developer-45390f460002?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/45390f460002</guid>
            <category><![CDATA[development]]></category>
            <category><![CDATA[gcp]]></category>
            <category><![CDATA[startup]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[flutter]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Tue, 08 Mar 2022 13:46:46 GMT</pubDate>
            <atom:updated>2022-07-09T20:25:13.491Z</atom:updated>
            <content:encoded><![CDATA[<p>Time flies! Here are some thoughts, tips, and mistakes after running a tech company as a solo developer for almost a year.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lENffNJqMW4fiDhqz7PlNw.png" /><figcaption>The final product after a year as a solo developer</figcaption></figure><h3>What am I building? ❓</h3><p>I had the idea that I wanted to build a Crossfit training app for gyms and athletes. I named it <a href="https://www.dreamwod.app/">Dreamwod</a> and just launched it on <a href="https://apps.apple.com/se/app/dreamwod/id1541012149">App Store</a> and <a href="https://play.google.com/store/apps/details?id=com.dreamwod.app">Google Play</a>. The things that I have been building are two apps, one for iOS and one for Android, a backend API, and a company web page.</p><p>This article will talk about the tech stack, ways of working, what I have learned and what mistakes I made.</p><h3>Deciding on the Tech Stack 🤔</h3><p>The requirements I had on the tech stack were the following.</p><p><strong>Backend and operations</strong></p><ol><li>Quick development and iteration time.</li><li>Easy to run locally.</li><li>Easy to scale the backend / be able to handle high load.</li><li>Easy to deploy.</li><li>Keep it as simple as possible.</li></ol><p><strong>App development</strong></p><ol><li>Easy to learn (I didn’t have any experience of iOS app development).</li></ol><h3>The stack</h3><h4>Backend - Golang 💻</h4><p>The language of choice for the backend was golang. I wanted something that was easy to learn, quick to compile, had a good performance, and had a low memory and CPU footprint in production.</p><h4>Apps — Flutter ☎️</h4><p>I didn’t have a lot of experience with either iOS or Android and I wanted apps for both platforms so the decision was pretty easy, try Flutter and see if it works. Very happy with that decision!</p><h4>Database — PostgreSQL 🏬</h4><p>I had several options available when picking a database. The assumptions I based the decision on were,</p><ul><li>Data will be shared between users, i.e. the data in the app is not only viewed and edited by the user uploading it.</li><li>I would have some joins and interconnected tables.</li><li>I assumed that I wanted support for full-text search and didn’t want to spend the time and energy on ElasticSearch or something similar.</li></ul><p>The decision was to go with Postgres. It got full-text search support out of the box with the <a href="https://www.postgresql.org/docs/12/pgtrgm.html">pg_trgm</a> extension. It also has good support for PostGIS with the <a href="https://postgis.net/">postgis</a> extension so it’s easy to calculate coordinates and distance from the user to for example a gym.</p><h4>Cloud — GCP ☁️</h4><p>I looked at two options for hosting, Google Cloud Platform (GCP)and AWS. I picked GCP because I could then run the backend API on Cloud Run and only pay for the processing time, which basically has been zero during development and beta.</p><p>Some other useful tools that I’ve been using,</p><p><strong>Cloud Build</strong> for building containers. Since I’m the only developer and not building that many containers then I’m easily within their free their of 120 minutes/day.</p><p><strong>Cloud Storage</strong> is used for content uploaded by users.</p><p><strong>Pub/sub </strong>is used for async processing, see the <a href="https://medium.com/dreamwod-tech/asynchronous-processing-with-cloud-run-and-pub-sub-16f80e76fe46">article</a> for how I use an approach where I can develop locally as well as deploy it easily to GCP.</p><p>The monthly bill right now is around $30, of that is the two largest services load balancer for the CDN $18 and the Postgres $10. This bill will increase when I get more users and need to use larger database instances but the cost should still be fairly low for quite some users.</p><h4>Emails — Mailgun 📫</h4><p>There are a lot of options out there, just picked Mailgun since I have used them in the past.</p><h4>Payments — Stripe 💰</h4><p>I’m adding support for subscriptions in the app and using Stripe was a good decision since they have a subscription portal for users where they can cancel and change subscriptions.</p><h4>Company Web Page — Webflow 🕸</h4><p>I picked webflow as the framework/tool for the company web page. I was looking for something simple that I could set up and then forget. Web page available at <a href="https://www.dreamwod.app/">www.dreamwod.app</a>.</p><h3>Way of working</h3><h4>No tests 🛑</h4><p>Not common to see this statement and maybe it’s something that will turn out to be a mistake later on but here are the top reasons why I haven’t added more than 5-10 tests in the backend project.</p><ul><li>As the solo developer (Backend, Frontend, QA) then I will test the backend code when I implement the frontend.</li><li>Adding tests takes a lot of time. When building this app it has been a lot of experimenting and trying out ideas and adding tests for something I wouldn’t use would slow down the development speed.</li><li>The things that I have tests for are mostly regular expression-related parsing, so it was quicker to write tests compared to testing the API directly.</li></ul><h4>Monolith instead of microservices 🐻</h4><p>Always a bit controversial to go with one service instead of a microservice strategy but the reason why I only have one backend API/service is that.</p><ul><li>I don’t want to spend time defining contracts/API and solving hosting-related problems that come with a multi-service approach.</li><li>It’s hard to know where to split a single service into different ones when starting a new project. My bet is that it’s easier to do this later if/when it’s needed.</li><li>I don’t have a lot of load on the system so I don’t have any issues with performance bottlenecks or special flows in the backend that needs to be optimized.</li></ul><p>With this said, it’s of course still good to divide the code into different areas/domains and not have “spaghetti code” in the codebase.</p><h3>What have I learned? 🎒</h3><ul><li>“The last 20% takes 80% of the time”, is a famous quote but it’s been true for me. The last things have taken <strong>a lot</strong> longer time than anticipated.</li><li>Working on my own means that I don’t have any strict deadlines but it can be hard to focus. I have learned that for me it works best if I have as many routines as possible when it comes to coding, training, etc as you have in a normal 9–5 job. A typical day would be one coding session before lunch, a longer lunch break with a gym session, and then a coding session in the afternoon.</li></ul><h3>What would I do differently in the Tech Stack?</h3><p>I am overall very happy with the tech stack and it isn’t a lot of things that I would have changed. But some things that I would have done differently are:</p><ul><li>I would have investigated if GraphQL API would have been a better option instead of a REST API.</li><li>I’m a bit concerned that it can be tricky to scale (let’s see if it ever will be needed) the solution globally. The database and services are hosted in Belgium&#39;s data center right now and there would be a high latency if someone would use it from for example US or Brazil.</li></ul><h3>What would I do differently from a product point-of-view?</h3><p>Sometimes it would have been better to take a “quick and easy” solution instead of a “correct one”.</p><ul><li>An example was that Cloud Run was kind of new when I started and they didn’t support some things like retrieving secrets from the GCP Secrets Manager. I spend some time on implementing something around that and it would have been better if I just did some quick and easy hack and then waited on proper support from Google Cloud.</li></ul><h3>Conclusions</h3><p>It’s always easy to look back at the things and to think about those things that didn’t go the way you planned, but it’s equally important to think about the things that actually worked out really well, that’s one of the reasons why I wrote this short article — to reflect on the things that worked good 😎.</p><p>If you are thinking about starting a company as a solo developer, stop thinking and do it! It’s a lot of fun! 🚀</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=45390f460002" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/how-i-built-my-tech-startup-as-a-solo-developer-45390f460002">How I built my tech startup as a solo developer</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Cloud Vision vs Flutter MLKit for OCR detection of Concept2 machine]]></title>
            <link>https://medium.com/dreamwod-tech/cloud-vision-vs-flutter-mlkit-for-ocr-detection-of-concept2-machine-514098f894af?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/514098f894af</guid>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[cloud-vision-api]]></category>
            <category><![CDATA[ml-kit]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Thu, 25 Nov 2021 11:44:37 GMT</pubDate>
            <atom:updated>2021-11-25T11:46:54.456Z</atom:updated>
            <content:encoded><![CDATA[<p>This is a comparison of two OCR detection alternatives for the contents of the Concept 2 rowing machine LCD display. MLKit in Flutter vs Cloud Vision at Google Cloud.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OQQtaxJxAur012_LLOP4Ow.jpeg" /><figcaption>Elite rower in action</figcaption></figure><h3>Background</h3><p><a href="https://www.dreamwod.app/">Dreamwod</a> is a Crossfit app where a lot of athletes are using the Concept2 machines for biking, rowing, and skiing. These machines have a memory where athletes can get statistics and split times over a workout. We have developed a feature where the athletes can take a picture of the screen (see below) and the data/text is recognized and imported directly into Dreamwod.</p><p>Below is an example screen of a 6x2000m interval rowing session with 4 minutes of rest between each interval. /500m is the average time per 500m and s/m means strokes per minute.</p><p>The source code with examples and descriptions are available here <a href="https://github.com/dreamwod-app/mlkit-vs-cloud-vision">https://github.com/dreamwod-app/mlkit-vs-cloud-vision</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/756/1*bIcNH23sWnRUKch1qQrLmw.jpeg" /><figcaption>An example image of the Concept 2 rowing machines screen</figcaption></figure><h3>The two alternatives, MLKit vs Google Cloud Vision</h3><p>We have looked at two different alternatives when performing the OCR. We looked at Google MLKit on the client-side and Cloud Vision API on the backend side, both alternatives have their drawbacks and advantages.</p><h4>Flutter MLKit</h4><p>The first alternative is to use <a href="https://pub.dev/packages/google_ml_kit">Flutter MLKit</a> and do the OCR scanning directly in the app and then send the extracted text to the backend. The advantage of this is obvious that the load is on the app side and we don’t have to do anything on the backend “more” than understanding the scanned content. The main drawback, which we will see later, is that the quality of the OCR is poor.</p><p><strong>Pros<br>+ </strong>Client/app side processing<br><strong>+ </strong>Free (no additional cost)<br><strong>+ </strong>Quick (~1 second to get the result)</p><p><strong>Cons<br>- </strong>Bad quality on the OCR <br><strong>- </strong>Does not work on all devices</p><h4><strong>Google Cloud Vision API</strong></h4><p>The second alternative is to use Cloud Vision API, upload the image and do the processing on the backend side, for example with Cloud Run or a Cloud Function.</p><p><strong>Pros<br>+ </strong>Good performance <br><strong>+ </strong>Works on older devices</p><p><strong>Cons<br>- </strong>Increased backend load<br><strong>- </strong>Additional cost (*)</p><p>(*) The first 1000 calls every month to the Vision API are free and then the <a href="https://cloud.google.com/vision/pricing">price</a> is $1.5 per 1000 requests.</p><h3><strong>Implementations</strong></h3><h4>Cloud Vision API</h4><p>Full implementation of the example program is available at <a href="https://github.com/dreamwod-app/mlkit-vs-cloud-vision">https://github.com/dreamwod-app/mlkit-vs-cloud-vision</a>. Check out the code, compile and run it with one of the example images below.</p><pre>$ git clone git@github.com:dreamwod-app/mlkit-vs-cloud-vision.git<br>$ go build<br>$ ./mlkit-vs-cloud-vision vision -image images/example_2.jpg -out output/example_vision_2.jpg</pre><p>The program will read the example image and call the Cloud Vision API. The program will then use the response and draw rectangles around the red rectangles where the text was found.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/756/1*JNb_3aig9P_kgu3DfZkmQA.jpeg" /><figcaption>Example of detected text with Cloud Vision API</figcaption></figure><p>The examples in the Github repository contain four different images with different quality and resolutions. It’s possible to use any image, just replace the <em>-image</em> and the <em>-out</em> arguments.</p><pre>$ ./mlkit-vs-cloud-vision vision -image any-image.jpg -out out.jpg</pre><h4><strong>MLKit with Flutter</strong></h4><p>There are some different flutter packages out there for ML but the most up-to-date is <a href="https://pub.dev/packages/google_ml_kit">google_ml_kit</a> which is using the models from the Google MLKit at <a href="https://developers.google.com/ml-kit">https://developers.google.com/ml-kit</a>.</p><p>The example below will output coordinates of the detected areas and a file with these can be provided to the golang library and to draw a similar image as for Cloud Vision.</p><pre>$ ./mlkit-vs-cloud-vision draw -image images/example_1.jpg -out output/example_mlkit_1.jpg -coords coords/coords1.txt</pre><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/3a4c259148f7281d56b1044eb1651931/href">https://medium.com/media/3a4c259148f7281d56b1044eb1651931/href</a></iframe><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*l5Iib9eMXKhINMSv-FQ5Ug.jpeg" /><figcaption>Example of detected text with MLKit</figcaption></figure><h3><strong>Results</strong></h3><p>It looks like the examples above are pretty similar but it turns out that there is a huge difference between the Cloud Vision API and MLKit. One of the four images couldn’t be used at all with MLKit and the quality of the extracted text is poor.</p><ul><li>One of the four images couldn’t be used with MLKit, no text was detected.</li><li>MLKit failed to detect a lot of numbers. It especially has trouble seeing the difference between “3” and “B”, 3.41.8 is seen as B.41.8.</li><li>MLKit see number very differently, 2:03.5 are for example returned as 2:03.5, 2:035, 2:03,5, 203.5 etc.</li><li>The only real issue for Cloud Vision was that it couldn’t detect “s” and “/” correctly when the s was superscripted in “s/m”.</li></ul><p>The table below is a summary of the result, Cloud Vision detects almost everything, and MLKit has quite some texts that aren’t recognized correctly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/864/1*6v9FFzIWSfbYOC7oTCpteQ.png" /><figcaption>Percentage of text that is identified correctly</figcaption></figure><p>Complete results are available at <a href="https://github.com/dreamwod-app/mlkit-vs-cloud-vision#results">https://github.com/dreamwod-app/mlkit-vs-cloud-vision#results</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/742/1*iCmMVwV9Pzkrc4ZDMIzPmg.jpeg" /><figcaption>MLKit failed to do text recognition</figcaption></figure><h3>Conclusion</h3><p>Doing machine learning on the client/app side is cool but the quality of the recognized texts is in our use case not good enough. There could be other scenarios where it is working better and with higher quality. We are because of that using Cloud Vision API for the upcoming feature of importing the Concept 2 results directly into Dreamwod.</p><p>Hope it helps when choosing between Cloud Vision and MLKit for text recognition! 🚀</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=514098f894af" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/cloud-vision-vs-flutter-mlkit-for-ocr-detection-of-concept2-machine-514098f894af">Cloud Vision vs Flutter MLKit for OCR detection of Concept2 machine</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Sign in with Apple and Google in Flutter App + Golang backend]]></title>
            <link>https://medium.com/dreamwod-tech/sign-in-with-apple-and-google-in-flutter-app-golang-backend-ea2e5b0491d0?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/ea2e5b0491d0</guid>
            <category><![CDATA[apps]]></category>
            <category><![CDATA[authentication]]></category>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[best-practices]]></category>
            <category><![CDATA[golang]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Sun, 24 Oct 2021 20:18:40 GMT</pubDate>
            <atom:updated>2021-10-24T20:18:40.351Z</atom:updated>
            <content:encoded><![CDATA[<p>This article describes the signup and sign-in flow for users in the Dreamwod Flutter app using AppleId and Google with a backend in Golang.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2Nsh3CHvFI6v12z3WPer1g.png" /><figcaption>Sign up with Apple and Google</figcaption></figure><p>The <a href="http://www.dreamwod.app/">Dreamwod app</a> supports three ways of signing up, Email&amp;Password, with Apple Id, or through Google. This article will describe the setup for AppleId and Google both in the flutter app as well in the golang backend.</p><h3>Our requirements</h3><ul><li>Apple Id and Google should work on both iOS and Android (since users can have with multiple devices)</li><li>Use available flutter packages if they are available</li><li>No hard dependency on Firebase (i.e. we don’t store users in their database)</li><li>Backend in Golang</li></ul><h3>Sign in with Apple Id</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*kmxdkkqMFyqdm3UbnJ6hng.png" /></figure><p><strong>Outline of the flow (on iOS):</strong></p><ol><li>The user clicks SignIn with Apple.</li><li>User confirms and an authentication token is received from Apple.</li><li>The token is sent from the app to the backend.</li><li>The backend validates the token to make sure it’s valid.</li><li>The user is created or logged in.</li><li>JWT access and refresh tokens are returned to the user.</li></ol><p>The package <a href="https://pub.dev/packages/sign_in_with_apple">sign_in_with_apple</a> is the package that we are using for authentication in the app.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/45ca3dd14000d9fa041648c37f4f132d/href">https://medium.com/media/45ca3dd14000d9fa041648c37f4f132d/href</a></iframe><p>The <em>webAuthenticationOptions</em> are needed if the user is signing in from an Android device, more about that later.</p><p>The sign-in request that is sent from the app to the backend looks like below. First and last name needs to be included since it’s not part of the authorization code. A flag if the request is from Android or not is also included since those need to be validated by a different ClientId in the backend.</p><pre>final request = AppleSignInRequest(<br>  firstName: credentials.givenName,<br>  lastName: credentials.familyName,<br>  authorizationCode: credentials.authorizationCode,<br>  isAndroid: Platform.<em>isAndroid</em>,<br>);</pre><p>The backend needs to validate the authorization code to make sure that it is valid. The go package <a href="https://github.com/Timothylock/go-signin-with-apple">go-signin-with-apple</a> is used for validating and tokens. The information that is needed for validation is <em>teamId</em>, <em>clientId</em>, <em>keyId,</em> and the private key. We also use a different clientId for android, see below.</p><pre>appleSignIn:<br>  teamId: &lt;...&gt;<br>  clientId: com.dreamwod.app<br>  androidClientId: com.dreamwod.app.login<br>  keyId: &lt;...&gt;<br>  privateKey: |<br>    -----BEGIN PRIVATE KEY-----<br>    ....<br>    -----END PRIVATE KEY-----</pre><p>The authenticator that is validating the token is shown below, the validation returns the unique id and the email of the user.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/a76353aa449f255b61bad553c5770a81/href">https://medium.com/media/a76353aa449f255b61bad553c5770a81/href</a></iframe><p>The flow is pretty straightforward on iOS. It&#39;s a bit more complicated on Android devices since those are using a web-based flow.</p><p><strong>Outline of the flow (on Android):</strong></p><ol><li>The user clicks SignIn with Apple.</li><li>A web view is opened and the user logs in with Apple credentials</li><li>The user is redirected back to the <em>redirectURI</em> in the <em>webAuthenticationOptions.</em></li><li>Our server at <em>redirectURI</em> redirects the user back to the app with the payload from Apple</li><li><em>Same steps as in the iOS flow…</em></li></ol><h4>Redirect URI</h4><p>In our case, we have the server running and endpoint at <em>auth/callbacks/apple-sign-in </em>and when we receive a POST request the below code is executed and there is a temporary redirect back to the app. The <em>AndroidManifest.xml</em> needs to be updated with the instructions at <a href="https://pub.dev/packages/sign_in_with_apple">sign_in_with_apple</a> for it to work.</p><pre>data, err := ioutil.ReadAll(c.Request.Body)<br><br>if err != nil {<br>   _ = c.AbortWithError(http.<em>StatusInternalServerError</em>, err)<br>   return<br>}<br><br>c.Redirect(http.<em>StatusTemporaryRedirect</em>, &quot;intent://callback?&quot;+string(data)+&quot;#Intent;package=com.dreamwod.app;scheme=signinwithapple;end&quot;)</pre><h3>Sign in with Google</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4W_RssyE3yDso9wEXo7ghQ.png" /><figcaption>Sign in with Google</figcaption></figure><p><strong>Outline of the flow (both iOS and Android):</strong></p><ol><li>The user clicks SignIn with Google.</li><li>User confirms and an id token is received.</li><li>The token is sent from the app to the backend.</li><li>The backend validates the token to make sure it’s valid.</li><li>The user is created or logged in.</li><li>JWT access and refresh tokens are returned to the user.</li></ol><p>The package <a href="https://pub.dev/packages/google_sign_in">google_sign_in</a> is the package that we are using for the sign-in.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1b4da7349f50bcf8af6de26e546f7f09/href">https://medium.com/media/1b4da7349f50bcf8af6de26e546f7f09/href</a></iframe><p>The id token is returned from the sign-in operation and sent to the backend. We don’t get the first and the last name of the user in the same way as through Apple.</p><pre>final request = GoogleSignInRequest(<br>  idToken: idToken<br>);</pre><p>The backend validates and the id token and retrieves email, first and last name. More information can be requested by extending the scope request. The audience is validated, we have two audiences, one for iOS and one for Android so the audience parameter to <em>idToken.Validate(context, token, audience)</em> isn’t used.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/385560487da456b16b7baa6d684d0935/href">https://medium.com/media/385560487da456b16b7baa6d684d0935/href</a></iframe><p>The Google flow is a bit easier to set up since the same flow is used both for iOS and Android.</p><h3>Conclusions &amp; Tips</h3><ul><li>Be sure to allow CORS for <a href="https://appleid.apple.co/">https://appleid.apple.co</a>m in your backend.</li><li>See <a href="https://medium.com/dreamwod-tech/flutter-bloc-authentication-flow-e0bcdfdd19c2">Flutter bloc authentication flow</a> for a walkthrough of an authentication bloc flow in the app.</li><li>Decide on if you should threaten Google users as separate users from Email and password users or if they can log in through their Google account and/or password.</li><li>Make sure to test the flows on both iOS and Android devices and make sure to test different and weird scenarios.</li><li>Make sure to store the unique apple id of the users in your backend and that as an identifier for the user</li><li>Consider using different login paths in your backend, like <em>POST /auth/apple, POST /auth/google, </em>POST <em>/auth/email </em>but return the same response back from all of them so the code in the app can share the same logic. In our case, we return email, JWT access&amp;refresh codes, and the authentication method.</li><li>Consider storing the last used authentication method in the app. In our app, we use it when the user logs out since we only want to pre-fill the email field only if the user did authenticate with email and password.</li></ul><p>That’s it! 🚀</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ea2e5b0491d0" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/sign-in-with-apple-and-google-in-flutter-app-golang-backend-ea2e5b0491d0">Sign in with Apple and Google in Flutter App + Golang backend</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Flutter + Dio Framework + Best practices]]></title>
            <link>https://medium.com/dreamwod-tech/flutter-dio-framework-best-practices-668985fc75b7?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/668985fc75b7</guid>
            <category><![CDATA[dart]]></category>
            <category><![CDATA[architecture]]></category>
            <category><![CDATA[flutter]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Fri, 08 Oct 2021 17:29:37 GMT</pubDate>
            <atom:updated>2021-10-08T17:33:52.643Z</atom:updated>
            <content:encoded><![CDATA[<p>This article will describe best practices for REST communication in Flutter using the Dio http package. The <a href="https://www.dreamwod.app/">Dreamwod</a> app used the standard http package but we recently migrated to the Dio http package, this article will describe why and some best practices around the architecture.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DqoK73b5nPC8KWs1IkC4cw.jpeg" /></figure><h4><strong>Background</strong></h4><ul><li>The Dreamwod app is talking with a backend Rest API.</li><li>The API is secured with JWT Tokens. One refresh token, long TTL, is exchanged to access tokens with short TTL.</li></ul><h4>Why Dio?</h4><p>The primary reasons why we migrated from the standard http package to Dio are:</p><ul><li>It’s easier to add interceptors when handling requests and errors (i.e when refreshing JWT tokens).</li><li>Less boilerplate code.</li><li>Cleaner code.</li></ul><h4>Architecture</h4><p>Our flutter app is structured in the following way:</p><ul><li><strong>lib/models: </strong>RPC and models with the standard JSON-serializable annotations.</li></ul><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4c0b702c2497310c27e5ef9768066eee/href">https://medium.com/media/4c0b702c2497310c27e5ef9768066eee/href</a></iframe><ul><li><strong>lib/services: </strong>We use one service for every part of the API, for example, user_service.dart, about_service.dart, notification_service.dart etc.</li></ul><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/02d40a2bda3a45b5f8f653ac6b8c224d/href">https://medium.com/media/02d40a2bda3a45b5f8f653ac6b8c224d/href</a></iframe><ul><li><strong>lib/api: </strong>Services are using an API class for communicating with the backend. We have two Dio clients in the class, one is used for all requests except when the token is refreshed, then we use a dedicated Dio client.</li></ul><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/bf9bdae7c963288840d7a2130fe97fe4/href">https://medium.com/media/bf9bdae7c963288840d7a2130fe97fe4/href</a></iframe><h4>Handling refresh of tokens</h4><p>We have a 15 minutes time-to-live on our access tokens and we refresh them if they have less than 1 minute remaining, see above. We use <em>requestlock.lock()</em> and <em>requestlock.unlock() </em>so we don’t have concurrent refresh requests.</p><p>The package <a href="https://pub.dev/packages/jwt_decode">jwt_decode</a> can be used to parse the expiration time from a JWTtoken.</p><h4>Error handling</h4><p>Using exceptions makes it easier to catch and handle different kinds of errors. Our error interceptor below which handles different HTTP status codes and converts them to appropriate exceptions.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/bb4ff6d833b693a39dd0ade16e5fc22a/href">https://medium.com/media/bb4ff6d833b693a39dd0ade16e5fc22a/href</a></iframe><p>Example of a “catch all” try-catch block.</p><pre>try {<br>  var user = await UserService().getUser();<br><br>  yield UserLoaded(user: user);<br>} on Exception catch (e) {<br>  yield LoadUserFailed(error: e.toString());<br>}</pre><p>Example of a when different exceptions are caught.</p><pre>try {<br>    final response =<br>        await UserService().login(event.email, event.password);<br><br>    //... other authentication code</pre><pre>    yield SignInSuccess();<br>  } on UnauthorizedException {<br>    yield SignInFailure(&#39;Invalid email or password. Please try again.&#39;);<br>  } catch (error) {<br>    yield SignInFailure(error.toString());<br>  }<br>}</pre><h4>Summary</h4><p>That&#39;s it! Hope it helps!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=668985fc75b7" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/flutter-dio-framework-best-practices-668985fc75b7">Flutter + Dio Framework + Best practices</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Digital Signal Processing with golang]]></title>
            <link>https://medium.com/dreamwod-tech/digital-signal-processing-with-golang-b7c1682c0b43?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/b7c1682c0b43</guid>
            <category><![CDATA[dsp]]></category>
            <category><![CDATA[heartbeat]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[grafana]]></category>
            <category><![CDATA[fast-fourier-transform]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Wed, 28 Apr 2021 10:16:45 GMT</pubDate>
            <atom:updated>2021-04-28T10:16:45.979Z</atom:updated>
            <content:encoded><![CDATA[<p>Digital signal processing (DSP) in golang, frequency spectrum analysis with FFT, and r-peak detection.</p><p>This article will show how golang can be used for digital signal processing (DSP) on a raw PPG or ECG heartbeat signal. We will use the fast Fourier transform (FFT) to look at the frequency spectrum, clean up noise from the signal using low and highpass filter, and finish by implementing r-peak detection on the signal to calculate the r-interval and the average heartbeat.</p><p>To outline the different steps,</p><ol><li>Read and visualize the signal</li><li>Frequency analysis with FFT and signal filtering</li><li>R-peak detection and heartbeat calculation</li><li>Conclusions</li></ol><h4>Source code and packages used</h4><p><a href="https://github.com/eripe970/go-dsp-utils">https://github.com/eripe970/go-dsp-utils</a> is the go package used in this article, it’s a wrapper around some of the most popular packages for DSP in golang. The example used in this article is also available in that repository.</p><p>To run the example just do</p><pre>$ git clone github.com/eripe970/go-dsp-utils<br>$ cd go-dsp-utils<br>$ go run examples/advanced/main.go</pre><p>And then go to localhost:4000</p><h4>1. Read and visualize the signal</h4><p>The chart below shows the signal that we will use as an example in this article. Each peak in the signal is an r-peak and our goal is to calculate the time between the peaks and by that calculate the heart rate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v2VrODZjZhbdzJHKn2T1SA.png" /><figcaption>Original 50s signal</figcaption></figure><p>Importing a signal from a file can be done with the utility function <em>ReadSignalFile</em>.</p><pre>signal, _ := dsp.ReadSignalFile(&quot;example_signal_100_hz.txt&quot;, 100)</pre><p>We can then also look at a 10 seconds sample of the signal with the code.</p><pre>signal10s := signal.Sample(10 * time.<em>Second</em>)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*g4KAI9ZWBd_QoO013aRLjQ.png" /><figcaption>10 seconds sample</figcaption></figure><h4>2. Frequency analysis with FFT</h4><p>We need to normalize the amplitude of the signal between -1 and 1 to more easily look at the frequency spectrum. An algorithm for that is implemented so we can easily get a normalized signal with the code.</p><pre>normalized, err := signal.Normalize()</pre><p>Calculating the Fast Fourier Transform (FFT) on the signal means that we can look at the signal in the frequency domain instead of the time domain. The code is not that complicated, the FFT is first calculated from the signal. Then we compute the two-sided spectrum spectrum2and after that, we compute the single-sided spectrum spectrum1 based on spectrum2 and the length of the signal.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c81bf1e8a486b6704bda1086306c9ba8/href">https://medium.com/media/c81bf1e8a486b6704bda1086306c9ba8/href</a></iframe><p>If we now plot the frequency spectrum of the signal we see something like below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lFnES8SwHvMHgKXrVOsCRA.png" /><figcaption>Frequency spectrum</figcaption></figure><p>We see that the peak around 0.92Hz (55 bpm). The next step that we want to do is to run the signal through a bandpass filter to remove some of the noise from the low and high frequencies that we don’t care about. The code to do that is,</p><pre>filtered, err := signal.BandPassFilter(0.5, 1.2)</pre><p>We assume that we in our example only care about heart rates between 30 BMP (0.5Hz)and 70 bpm (1.2Hz).</p><p>If we then look at the final signal it looks like below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YpkHkUWbrc6D8WHbz3tGLA.png" /><figcaption>Normalized signal after low and high pass filters</figcaption></figure><h4>3. R-peak detection</h4><p>Detecting the heart-beats is as simple as doing a peak detection on the signal. In our library, it’s done with the code.</p><pre>rPeaks := dsp.GetRPeaks(signal) </pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*WzPRNd4B6qKFRtjLR95tZA.png" /><figcaption>R-peak detected</figcaption></figure><p>Not easy to see in the graph but each red line is where an r-peak is detected. Displaying the r-interval time and the heartbeat gives us the following charts.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Km4AB9Qtj7H0JJIB7SfqLA.png" /><figcaption>R-interval time</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PtT1ZNOECSdKmE4NqR1-XQ.png" /><figcaption>Heartbeats</figcaption></figure><h4>4. Conclusions</h4><p>That’s all for the first part! The source code is available at <a href="https://github.com/eripe970/go-dsp-utils">https://github.com/eripe970/go-dsp-utils</a>.</p><p>The next article will continue on this one and we will use TimescaleDB and Grafana to calculate and plot the heartbeats in real-time.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b7c1682c0b43" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/digital-signal-processing-with-golang-b7c1682c0b43">Digital Signal Processing with golang</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Flutter bloc authentication flow]]></title>
            <link>https://medium.com/dreamwod-tech/flutter-bloc-authentication-flow-e0bcdfdd19c2?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/e0bcdfdd19c2</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[authentication]]></category>
            <category><![CDATA[crossfit]]></category>
            <category><![CDATA[dart]]></category>
            <category><![CDATA[bloc]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Mon, 22 Feb 2021 22:26:47 GMT</pubDate>
            <atom:updated>2021-02-22T22:26:47.140Z</atom:updated>
            <content:encoded><![CDATA[<p>Part 2 in the complete flutter app walkthrough. Designing an onboarding and authentication flow with the bloc framework.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bNMSRGghtfc_gl48GqLGSA.png" /></figure><p>This is the second post in a series on how the <a href="https://www.dreamwod.app/">Dreamwod</a> app is built. The Dreamwod app is an iOS and Android app for CrossFit athletes where they can track their progress and for CrossFit gyms that want their athletes to engage in their community. We have previously described the components and package used to build a “details page” in the app. This post will take a step back and instead look at the onboarding flow and how the bloc pattern can be used to structure the logic around onboarding and authentication.</p><p>The series contains (at least) the following posts.</p><ol><li><a href="https://medium.com/dreamwod-tech/complete-flutter-app-walkthrough-part-1-4278496698cf">Components used for building the details page</a>.</li><li>Flutter bloc authentication flow <em>(this post)</em>.</li><li>Push notifications setup <em>(available later)</em>.</li><li>Components used for building the profile page <em>(available later)</em>.</li><li>Integrating Stripe for payments <em>(available later)</em>.</li><li>Build and publish with Fastlane <em>(available later)</em>.</li></ol><h3>Prerequisites</h3><h4>Bloc pattern</h4><p><a href="https://pub.dev/packages/flutter_bloc">Flutter_bloc</a> is a package that can be used to separate the presentation logic from the business logic. It is typically achieved by sending events into a bloc and have the bloc update the app state. A lot of good examples are available <a href="https://bloclibrary.dev/#/gettingstarted">here</a>.</p><h4>Secure storage</h4><p>Our app uses JWT tokens for authentication. The JWT is sensitive information and should be stored in a secure way. We are using the <a href="https://pub.dev/packages/flutter_secure_storage">flutter_secure_storage</a> package for this and have wrapped the read and write logic into a repository which the app can use.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/bf44947b1db73fb930ffae27a50299e8/href">https://medium.com/media/bf44947b1db73fb930ffae27a50299e8/href</a></iframe><h3>Onboarding and authentication flow</h3><figure><img alt="" src="https://cdn-images-1.medium.com/max/500/1*I7Aw1uVPwcgLROn8ANkQ2A.png" /><figcaption>Overview of the onboarding flow</figcaption></figure><p>The above state diagram describes an overview of how the authentication flow works. Our app is wrapped in an <em>AuthenticationBloc</em> and the <em>AppStarted</em> event is fired directly at the start of the app.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/7647976b3a8bb857e766f526e30dbce5/href">https://medium.com/media/7647976b3a8bb857e766f526e30dbce5/href</a></iframe><p>The logic in the Authentication bloc is pretty simple, events are handled and the state is changed.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/6da6bc0edecb86ced682c18d896011b0/href">https://medium.com/media/6da6bc0edecb86ced682c18d896011b0/href</a></iframe><p>Some things are worth pointing out.</p><ol><li>This bloc is also a good place to set up other things like error logging towards Sentry, Firebase Analytics, register Firebase push notifications, etc.</li><li>The start state is <em>AuthenticationUninitialized</em>. We use this special state so that a loading spinner can be displayed when the <em>AppStarted</em> event is processed.</li><li>We check if the app is started for the first time after install/re-install. One problem with using the <em>flutter_secure_storage</em> package is that information is stored in the iOS keychain. Information in the key chain will be available even after the app is re-installed. That is not something that we want so we have a check that will clear everything after an install/re-install. The information if it’s the first run is stored in the SharedPreferences. SharedPreferences are deleted when the app is uninstalled.</li><li>This bloc doesn’t handle the logic for login. That is handled by a SignInBloc which emits a LoginEvent with the JWT token and the email of the user to the AuthenticationBloc. See the example below. This separates the logic in a good way between different blocs and views.</li></ol><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/7000f1ef5d5aaa72e87b69b7bb23a296/href">https://medium.com/media/7000f1ef5d5aaa72e87b69b7bb23a296/href</a></iframe><h4>Updating the UI on different states</h4><p>Different states map to different screens. The <em>AuthenticationAuthenticated </em>state is the only state that will display the app for authenticated users.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/eed5bc0c79c7dfae50895dc27c5797a8/href">https://medium.com/media/eed5bc0c79c7dfae50895dc27c5797a8/href</a></iframe><h4>Testing the flows</h4><p>Using a global observer on the blocs is a must to be able to follow between events and state transitions. The observer below will log all events and states and make it easier to follow the transitions. Can be added with <em>Bloc.observer = LoggingBlocDelegate();</em></p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8cf4c91712964210b7f96e1a470edccd/href">https://medium.com/media/8cf4c91712964210b7f96e1a470edccd/href</a></iframe><p><strong>Example 1. Starting the app without a JWT Token.</strong></p><blockquote>Transition { currentState: Instance of ‘AuthenticationUninitialized’, event: Instance of ‘AppStarted’, nextState: Instance of ‘AuthenticationUnauthenticated’}</blockquote><p>Straight forward, the event <em>AppStarted</em> is changing the state to <em>AuthenticationUnauthenticated</em> since the user isn’t authenticated.</p><p><strong>Example 2. Log in with email/password</strong></p><blockquote>Transition { currentState: Instance of ‘SignInInitial’, event: Instance of ‘SignInButtonPressed’, nextState: Instance of ‘SignInLoading’ } (SignInBloc)</blockquote><blockquote>Transition { currentState: Instance of ‘SignInLoading’, event: Instance of ‘SignInButtonPressed’, nextState: Instance of ‘SignInSuccess’ } (SignInBloc)</blockquote><blockquote>Transition { currentState: Instance of ‘AuthenticationLoading’, event: Instance of ‘LoggedIn’, nextState: Instance of ‘AuthenticationAuthenticated’ } (AuthenticationBloc)</blockquote><p>A bit more complicated. The first state transition happens when the user taps the SignIn button. The second one is the transition when the password is verified in the backend. The third transition is when the <em>AuthenticationBloc</em> processed the event that the user is logged in and changes the state.</p><p><strong>Example 3. Logout</strong></p><blockquote>Transition { currentState: Instance of ‘AuthenticationAuthenticated’, event: Instance of ‘LoggedOut’, nextState: Instance of ‘AuthenticationLoggingOut’ }</blockquote><blockquote>Transition { currentState: Instance of ‘AuthenticationLoggingOut’, event: Instance of ‘LoggedOut’, nextState: Instance of ‘AuthenticationUnauthenticated’ }</blockquote><p>State changes from <em>AuthenticationAuthenticated</em> &gt;<em>AuthenticationLoggingOut</em> &gt; <em>AuthenticationUnauthenticated</em>. It can take a second or two to log out so the state <em>AuthenticationLoggingOut</em> is used for displaying a loading spinner.</p><h3>Conclusions</h3><p>Using the bloc framework can be a good approach when designing a flow with different states, for example an authentication and onboarding flow. Some key takeaways.</p><ul><li>Don’t try to add too much logic into the same bloc, better to separate into several blocs.</li><li>Wrap the app in the main bloc responsible for authentication and use that to change between authenticated and unauthenticated states.</li><li>Use intermediate loading states for operations that take longer time.</li><li>Events can be added directly in the initialization of blocs with the <em>..add(MyEvent);</em> syntax.</li><li>Don&#39;t forget to test edge cases and error scenarios. The authentication flow must be rock solid!</li></ul><p>Good luck! 💪</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e0bcdfdd19c2" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/flutter-bloc-authentication-flow-e0bcdfdd19c2">Flutter bloc authentication flow</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Complete Flutter app walkthrough Part 1]]></title>
            <link>https://medium.com/dreamwod-tech/complete-flutter-app-walkthrough-part-1-4278496698cf?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/4278496698cf</guid>
            <category><![CDATA[flutter]]></category>
            <category><![CDATA[flutter-package]]></category>
            <category><![CDATA[flutter-ui]]></category>
            <category><![CDATA[app-development]]></category>
            <category><![CDATA[flutter-app-development]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Tue, 09 Feb 2021 23:28:11 GMT</pubDate>
            <atom:updated>2021-10-11T13:51:56.018Z</atom:updated>
            <content:encoded><![CDATA[<h3>Complete Flutter app walkthrough part 1</h3><p>Components, best practices, and tools used to build a large Flutter app.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ltwUp02KwAwxUEcvDWFwKQ.png" /><figcaption>The Dreamwod app</figcaption></figure><p>This is the first post in a series on how the <a href="https://www.dreamwod.app/">Dreamwod</a> app is built. The Dreamwod app is an iOS and Android app for CrossFit athletes where they can track their progress and for CrossFit gyms that want their athletes to engage in their community. We have previously described how we have created some of the backend services (Golang + CloudRun at Google Cloud) and now it’s time to discuss the app.</p><p>The series contains (at least) the following posts.</p><ol><li>Components used for building the details page (this post).</li><li><a href="https://lazyloadin.medium.com/flutter-bloc-authentication-flow-e0bcdfdd19c2">Flutter authentication flow with bloc pattern</a>.</li><li><a href="https://medium.com/dreamwod-tech/flutter-dio-framework-best-practices-668985fc75b7">Flutter + dio framework + best practices</a></li><li>Components used for building the profile page <em>(available later)</em>.</li><li>Integrating Stripe for payments <em>(available later)</em>.</li><li>Build and publish with Fastlane <em>(available later)</em>.</li></ol><h3>Scoreboard screen</h3><p>In the Dreamwod app, athletes register their scores in different daily workouts. The scoreboard result screen displays the scores and athletes can navigate to a certain score to check out the details. This post focus on the details page and the different components used to build it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/391/1*z7fY19vmh0TWt6ZbxU09mw.png" /><figcaption>Score screen in the Dreamwod app</figcaption></figure><p>The scoreboard results screen is pretty simple with different list tiles group by different categories. The focus of this post is the details page for the scores.</p><h3>Details page</h3><p>The details page is the page where a score and comment about a workout is displayed. The functionality on this page is.</p><ul><li>Pull down to refresh the page.</li><li>Like/unlike the workout/score.</li><li>Add a comment (text and or emoji).</li><li>Add a gif comment (Giphy integration).</li><li>List comments and scroll to the latest one added.</li><li>Display nicely formatted posted date on comments.</li><li>Facebook style reactions (with 😂, 👍 or other emojis) on the comments.</li><li>Add reactions and display a chip/badge to show the number of reactions</li></ul><p>Additional functionality not seen on the image below is also</p><ul><li>Loading overlay</li><li>Haptic feedback</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*L0HOJsc6ECdMtklXI9JLEg.gif" /><figcaption>Animation of the details page</figcaption></figure><h4>Loading overlay</h4><p>The component <a href="https://pub.dev/packages/loading_overlay">loading_overlay</a> is used to display a loading overlay when data from the backend is loaded. <em>isLoading</em> is initialized to true and then unset when the data from the backend is available.</p><pre>Scaffold(<br>    backgroundColor: Theme.<em>of</em>(context).scaffoldBackgroundColor,<br>    appBar: AppBar(<br>      title: Text(&#39;Workout result&#39;),<br>    ),<br>    body: LoadingOverlay(<br>        isLoading: isLoading,<br>        child: ... )<br>);</pre><h4>Pull to refresh</h4><p>The package <a href="https://pub.dev/packages/pull_to_refresh">pull_to_refresh</a> is used for refreshing when the user pulls down.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*njDD-AV3grmA3uJanzTxJA.gif" /><figcaption>Pull down to refresh the screen</figcaption></figure><pre>SmartRefresher(<br>    enablePullDown: true,<br>    header: ClassicHeader(),<br>    onRefresh: onRefresh,<br>    controller: refreshController,<br>    child: ListView(controller: scrollController, children:<br>        getBody())));</pre><h4>Like button with animation</h4><p>The package <a href="https://pub.dev/packages/like_button">like_button</a> is used for creating the animations that are displayed when a user likes a workout. The logic is pretty straight forward and the like and count builders can be used to display different messages depending on how many likes there are.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*tp5eY8F8VtPkY4-W5tYRUA.gif" /><figcaption>Animations on the like button</figcaption></figure><pre>LikeButton(<br>  onTap: (onLikeButtonTapped),<br>  size: 26,<br>  isLiked: isLiked,<br>  likeCount: numberOfLikes,<br>  likeCountAnimationType: LikeCountAnimationType.all,<br>  likeBuilder: (bool isLiked) {<br>    return Icon(<br>      Icons.<em>favorite</em>,<br>      color: isLiked ? Colors.<em>pinkAccent </em>: Colors.<em>grey</em>,<br>      size: 26,<br>    );<br>  },<br>  countBuilder: (int count, bool isLiked, String text) {<br>    if (count == 0) {<br>      return Text(&#39;Tap to like!&#39;);<br>    } else if (count == 1 &amp;&amp; isLiked) {<br>      return Text(&#39;You like this!&#39;);<br>    }<br><br>    return Text(&#39;${count} ${(count == 1 ? &#39;athlete&#39; : &#39;athletes&#39;)} likes this&#39;<br>    );<br>  },<br>),</pre><h4>Emoji sizing</h4><p>The default size of emojis in flutter text is a bit too small for us. We want to scale them up so they stand out more around the text.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/349/1*4tNcF6AXc-kdFe-wXLByGg.png" /><figcaption>The normal size of emojis in text</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/343/1*SQXqIyGn1o7eNDnnVFd7_A.png" /><figcaption>Emojis scaled up 30%</figcaption></figure><p>This can be achieved by a component that scales up the text.</p><pre>class EmojiText extends StatelessWidget {<br>  final String text;<br>  final TextStyle style;<br>  final double emojiFontMultiplier;<br><br>  const EmojiText({<br>    @required this.text,<br>    @required this.style,<br>    this.emojiFontMultiplier = 1.3,<br>  });<br><br>  // Regex to match emojis<br>  static final <em>regex </em>= RegExp(<br>      &#39;((?:\u00a9|\u00ae|[\u2000-\u3300]|\ufe0f|[\ud83c-\ud83e][\udc00-\udfff]|\udb40[\udc61-\udc7f])+)&#39;);<br><br>  List&lt;TextSpan&gt; generateTextSpans(String text) {<br>    var spans = &lt;TextSpan&gt;[];<br>    final emojiStyle = style.copyWith(<br>      fontSize: (style.fontSize * emojiFontMultiplier),<br>      letterSpacing: 2,<br>    );<br><br>    text.splitMapJoin(<br>      <em>regex</em>,<br>      onMatch: (m) {<br>        spans.add(<br>          TextSpan(<br>            text: m.group(0),<br>            style: emojiStyle,<br>          ),<br>        );<br>        return &#39;&#39;;<br>      },<br>      onNonMatch: (s) {<br>        spans.add(TextSpan(text: s));<br>        return &#39;&#39;;<br>      },<br>    );<br>    return spans;<br>  }<br><br>  @override<br>  Widget build(BuildContext context) {<br>    return Container(<br>      child: RichText(<br>        text: TextSpan(children: generateTextSpans(text), style: style),<br>      ),<br>    );<br>  }<br>}</pre><p>We also have a utility function that can be used to check if all characters in a string are emojis so that the text can be scaled even more.</p><pre>class Utils {<br>  static final <em>emailRegExp </em>= RegExp(<br>      r&#39;^(([^&lt;&gt;()[\]\\.,;:\s@\&quot;]+(\.[^&lt;&gt;()[\]\\.,;:\s@\&quot;]+)*)|(\&quot;.+\&quot;))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$&#39;);<br><br>  static final <em>onlyEmojiRegExp </em>= RegExp(<br>      &#39;^(\u00a9|\u00ae|[\u2000-\u3300]|\ufe0f|[\ud83c-\ud83e][\udc00-\udfff]|\udb40[\udc61-\udc7f]|\s|\t|\n|\r)*\$&#39;);<br><br>  static bool <em>isOnlyEmojis</em>(String comment) {<br>    return <em>onlyEmojiRegExp</em>.hasMatch(comment);<br>  }<br>}</pre><p>Used for example like below</p><pre>EmojiText(<br>  text: comment,<br>  style: style,<br>  emojiFontMultiplier: Utils.<em>isOnlyEmojis</em>(comment) ? 3: 1.4,<br>);</pre><h4>Display time</h4><p>The component used to format time nicely is called <a href="https://pub.dev/packages/timeago">timeago</a>. It is that component that will format dates like “a moment ago”, “24 hours ago” etc.</p><h4>Facebook-style reaction button &amp; badges</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*RWg8OwezW9UakepJ_iwYSg.gif" /><figcaption>Facebook style reaction button and badges</figcaption></figure><p>The package used to create the reaction buttons is the package <a href="https://pub.dev/packages/flutter_reaction_button">flutter_reaction_button</a>. A flutter chip could have been used to create the badge counting the reactions but it is often too large even if with padding set to zero. The <a href="https://pub.dev/packages/badges">badges</a> package is the rescue!</p><h4>Gif support through giphy</h4><p>We are using the <a href="https://pub.dev/packages/giphy_picker">giphy_picker</a> package when adding images. The only code needed is below. The package contains all the logic and the only thing needed is to pick the URL and store it in the backend.</p><pre>GestureDetector(<br>    onTap: () async {<br>      final gif = await GiphyPicker.<em>pickGif</em>(<br>          context: context,<br>          apiKey: &#39;your-api-key&#39;);<br><br>      if (gif != null) {<br>        addGif(gif);<br>      }<br>    },<br>    child: Icon(Icons.<em>gif_rounded</em>));</pre><pre>void addGif(GiphyGif gif) {<br>  BlocProvider.<em>of</em>&lt;ScoreBloc&gt;(context).add(AddComment(<br>      url: gif.images.original.url));<br><br>  setState(() {<br>     // Todo: reset the focus in a better way<br>     FocusScope.<em>of</em>(context).requestFocus(FocusNode());<br>  });<br>}</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*AfXW8DyFVlNJn_SJvzlXIA.gif" /><figcaption>Adding a gif image as a comment</figcaption></figure><h4>Cupertino rounded corners</h4><p>A great flutter package to make Cupertino rounded corners (also referred to as squircles) is the <a href="https://pub.dev/packages/cupertino_rounded_corners">cupertino_rounded_corners</a>. See the example in the emoji scaling section.</p><h4>Cached network image</h4><p>Users will look at the same comments multiple times so it makes sense to cache the gif animations. An excellent package for this is <a href="https://pub.dev/packages/cached_network_image">cached_network_image</a>. It also supports a loading placeholder and an error widget.</p><pre>CachedNetworkImage(<br>  imageUrl: url,<br>  placeholder: (context, url) =&gt; Padding(<br>    padding: const EdgeInsets.symmetric(<br>        vertical: 40, horizontal: 80),<br>    child: Container(<br>      child: CircularProgressIndicator(),<br>    ),<br>  ),<br>  errorWidget: (context, url, error) =&gt;<br>      Icon(Icons.<em>error</em>),<br>);</pre><h4>Haptic feedback</h4><p>Haptic feedback can be added when for example the like button is pressed.</p><pre>Future&lt;bool&gt; onLikeButtonTapped(bool isLiked) async {<br>  // todo, code for handling the event<br><br>  await HapticFeedback.<em>lightImpact</em>();<br><br>  return !isLiked;<br>}</pre><p>That&#39;s all for the first part!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=4278496698cf" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/complete-flutter-app-walkthrough-part-1-4278496698cf">Complete Flutter app walkthrough Part 1</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Asynchronous processing with Cloud Run and Pub/Sub]]></title>
            <link>https://medium.com/dreamwod-tech/asynchronous-processing-with-cloud-run-and-pub-sub-16f80e76fe46?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/16f80e76fe46</guid>
            <category><![CDATA[cloud-functions]]></category>
            <category><![CDATA[pub-sub]]></category>
            <category><![CDATA[google-cloud-run]]></category>
            <category><![CDATA[gcp]]></category>
            <category><![CDATA[golang]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Mon, 08 Feb 2021 19:47:47 GMT</pubDate>
            <atom:updated>2021-02-09T18:23:23.272Z</atom:updated>
            <content:encoded><![CDATA[<p>Secure asynchronous processing with Cloud Run through Pub/Sub.</p><h3>Background</h3><p>Cloud Run will throttle the CPU when there isn’t an active request processing. That means that it isn’t possible to spawn a new thread or goroutine to do background processing within a Cloud Run API. Background processing can for example be to send an email during registration or to send push notifications when a user likes a post, something that the user doesn’t have to wait for.</p><p>This article will describe a solution for asynchronous processing where an API running on Cloud Run will send events to Pub/Sub. These events will then be sent back to the API through a Pub/Sub push subscription.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FxOuUdGuwaBLfgfNbghkEA.png" /><figcaption>Overview</figcaption></figure><ol><li>An API request is sent to a Cloud Run service.</li><li>The API request is processed and events are sent to an event topic in Pub/Sub.</li><li>The API response is returned back the client and the CPU will be throttled.</li><li>Pub/Sub pushes an event back to the API Service for asynchronous processing.</li></ol><p>In our example, we will have a golang REST API with two methods.</p><pre>POST /change-name    // Used to change the name of a user<br>POST /events         // Used by Pub/Sub for processing events</pre><h4>Source code and example</h4><p>The library used for signing and consuming events and an end-to-end example is available at <a href="https://github.com/eripe970/pubsub-signing/tree/main/example">https://github.com/eripe970/pubsub-signing</a>.</p><h3>Create events</h3><p>Pub/Sub required that the Cloud Run allows all public traffic to be able to receive events. It supports authentication of requests and as an extra security measurement, we will sign each message to be sure that they are sent from the API. The GCP requirement that the API must be public means that we cannot have an internal API that handles the events.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d25c6a1a1d2e35604256d8c5b7506f5a/href">https://medium.com/media/d25c6a1a1d2e35604256d8c5b7506f5a/href</a></iframe><p>The event-type is stored on the message as an attribute. Each message is signed with a secret and the signature is verified when the event is processed in the event handler in the API.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/d17ceb4632161a604dbbbe347a90be72/href">https://medium.com/media/d17ceb4632161a604dbbbe347a90be72/href</a></iframe><p>The contents of the event will look like below.</p><pre>{<br>  &quot;message&quot;: {<br>    &quot;attributes&quot;: {<br>      &quot;event-type&quot;: &quot;user.name.changed&quot;,<br>      &quot;signature&quot;: &quot;cc6d0457d8b4ec0e994d793302cd6962a0d12101abbc79e561f532a826eca4ee&quot;<br>    },<br>    &quot;data&quot;: &quot;eyJOYW1lIjoibmV3LW5hbWUifQ==&quot;<br>  },<br>  &quot;subscription&quot;: &quot;api-events&quot;<br>}</pre><p><strong>Event-type</strong> will be used for the deserialization of the event when it is processed in the api.</p><p><strong>The signature</strong> is a hex-encoded signature that will be verified when the message is deserialized.</p><p><strong>Data</strong> is base64 encoded payload (automatically encoded by GCP).</p><p><strong>Subscription </strong>can be used to know which subscription the message originates from (automatically added by GCP).</p><h3>Consume Events</h3><p>The code below constructs the message and validates the signature. The event will also be deserialized depending on its type.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/332acf82f91cee02e3068e32851f97f0/href">https://medium.com/media/332acf82f91cee02e3068e32851f97f0/href</a></iframe><h3>Developing locally</h3><p>One option when developing locally is to use the Pub/Sub emulator from <a href="https://cloud.google.com/pubsub/docs/emulator">https://cloud.google.com/pubsub/docs/emulator</a>. Another option is to have two implementations for publishing messages. One to use at GCP that publishes messages to Pub/Sub and one to use locally that makes a HTTP request directly back to the API.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e2a82f9f82aaf68dc346ac9273018b9b/href">https://medium.com/media/e2a82f9f82aaf68dc346ac9273018b9b/href</a></iframe><h3>Cloud Run vs Cloud Functions</h3><p>Events can also be processed by a Cloud Function instead of an API on Cloud Run. Some pros with processing with Cloud Run instead of Cloud Function.</p><ul><li>Support languages/versions that Cloud Functions does not yet support (any docker image).</li><li>Cloud Run can handle multiple types of events/messages in one deployment.</li><li>May not be impacted by cold starts in the same way as Cloud Functions.</li></ul><h3>Summary</h3><p>Using Cloud Run in combination with a push subscription on Pub/Sub is a good alternative to Cloud Functions for handling asynchronous processing of events. Try it out!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=16f80e76fe46" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/asynchronous-processing-with-cloud-run-and-pub-sub-16f80e76fe46">Asynchronous processing with Cloud Run and Pub/Sub</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Migrating from GKE to Cloud Run]]></title>
            <link>https://medium.com/dreamwod-tech/migrating-from-gke-to-cloud-run-d4269db2edbc?source=rss-cc8ca1ed0899------2</link>
            <guid isPermaLink="false">https://medium.com/p/d4269db2edbc</guid>
            <category><![CDATA[migration]]></category>
            <category><![CDATA[gke]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[google-cloud-run]]></category>
            <dc:creator><![CDATA[Erik]]></dc:creator>
            <pubDate>Mon, 18 Jan 2021 20:15:22 GMT</pubDate>
            <atom:updated>2021-02-09T18:19:15.837Z</atom:updated>
            <content:encoded><![CDATA[<p>Migrating a golang REST API from Kubernetes Engine (GKE) to Cloud Run</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*wptZbznyhyxnGfel7c_xvw.jpeg" /><figcaption>Migrating to Cloud Run</figcaption></figure><h3>Background</h3><p>Kubernetes at Google Cloud (GKE) was used during the development and the testing of the Dreamwod app (<a href="https://www.dreamwod.app/">https://www.dreamwod.app</a>). GKE has been working good but it has also been the most expensive and complicated part of the hosting. We have because of that been migrated from GKE to Cloud Run. The primary reasons behind the migrations are.</p><ol><li><strong>Cost</strong> - Cloud Run is cheaper than GKE (for our workload).</li><li><strong>Simplicity</strong> - Our API is pretty simple and we don’t need a lot of the extra features in Kubernetes.</li></ol><h4>Migrating secrets</h4><p>When migrating to Cloud Run we decided to store our secrets in Google Cloud Secret Manager. There isn’t a native Cloud Run integration with Secret Manager so the recommended way forward is to load any secrets when the container is starting. We decided to store the whole .yml config file in one secret so we can start the container after only one request to Secret Manager.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8e1d9396bad9bef44d109607c2acfee7/href">https://medium.com/media/8e1d9396bad9bef44d109607c2acfee7/href</a></iframe><h4>Migrating Cloud SQL</h4><p>We used cloud-sql-proxy as a sidecar when connecting to Cloud SQL from Kubernetes. The cloud-sql-proxy functionality is already built-in into Cloud Run so the only thing that needs to be done is to add the connection and to update the connection string. On Kubernetes we used the following configuration and the connection string postgresql://<em>username</em>:<em>passwor</em>d@127.0.0.1?sslmode=disable.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/08037ac32bd3dc1ec3171de1074b37bc/href">https://medium.com/media/08037ac32bd3dc1ec3171de1074b37bc/href</a></iframe><p>On Cloud Run we used the following connection string postgresql://<em>username</em>:<em>password</em>@:5432/<em>database-name</em>?host=/cloudsql/&lt;<em>instance-connection-name</em>&gt;</p><h4>Migrating asynchronous tasks</h4><p>We had certain functionality when we for example did processing of images in the background using goroutines. That had to be changed since Cloud Run throttle the CPU if no requests are active.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/79d94bd77ad136aaa26f346e55b8f495/href">https://medium.com/media/79d94bd77ad136aaa26f346e55b8f495/href</a></iframe><p>The new approach is instead to send messages to Cloud Pub/Sub when we need to do background processing and then have a subscription that pushes messages back to the API. The below code publishes events of different types to Pub/Sub.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/00178580564c11509b9c9ed4222db4e0/href">https://medium.com/media/00178580564c11509b9c9ed4222db4e0/href</a></iframe><h4>Migrating the build pipeline</h4><p>We use Cloud Build for building and deploying containers. Deploying with Cloud Run is just adding one extra step to the build pipeline.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e11470c09696bf892c377529f68dad1c/href">https://medium.com/media/e11470c09696bf892c377529f68dad1c/href</a></iframe><h4>Migrating DNS</h4><p>We added a subdomain mapping in Cloud Run, updated the DNS and the migration was finished.</p><h4>Updating logging</h4><p>We use <a href="https://github.com/gin-gonic/gin">gin-gonic</a> as the web framework and <a href="https://github.com/gin-contrib/zap">Zap</a> for logging.</p><ol><li>Request logging was disabled in <a href="https://github.com/gin-gonic/gin">gin-gonic</a> since Cloud Run is logging all requests.</li><li><a href="https://github.com/gin-contrib/zap">Zap</a> is updated with below to make Cloud Logging pick up the severity.</li></ol><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/98b46b85b523eb598526958ee1b3e51b/href">https://medium.com/media/98b46b85b523eb598526958ee1b3e51b/href</a></iframe><h3>Conclusion</h3><h4>Performance</h4><p>We don’t have a lot of load on the API but we haven’t been able to see any performance degradations after the move to Cloud Run.</p><h4>Startup time</h4><p>The startup time for processing a request when the instance is cold is impressive. For our API it’s between 1 and 2s. That includes starting the container, doing a database migration/update check and loading configuration from Secret Manager.</p><h4>Cost</h4><p>A typical month for us (everything hosted in Amsterdam datacenter) with Kubernetes was around <strong>$70/month</strong>.</p><ol><li>Compute Engine $58 (84%)</li><li>Cloud SQL $11 (15%)</li><li>Others $1 (1%)</li></ol><p>When running on Cloud Run we are currently in the free tier which means that the cost for Cloud Run is zero. The total cost is around <strong>$12/month</strong>.</p><ol><li>Cloud SQL $11 (91%)</li><li>Others $2 (9%)</li></ol><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=d4269db2edbc" width="1" height="1" alt=""><hr><p><a href="https://medium.com/dreamwod-tech/migrating-from-gke-to-cloud-run-d4269db2edbc">Migrating from GKE to Cloud Run</a> was originally published in <a href="https://medium.com/dreamwod-tech">Dreamwod tech</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>