From 33083907fba7e3ed549de56524c68fb9c75beb3e Mon Sep 17 00:00:00 2001 From: fiatcode Date: Thu, 22 Jan 2026 17:20:59 +0700 Subject: [PATCH] refactor: update titles and descriptions to use double quotes for consistency fix: add newlines and formatting improvements in blog posts refactor: update import paths to use alias for better readability style: update global CSS to use double quotes for consistency chore: update tsconfig to include path aliasing for cleaner imports --- astro.config.mjs | 10 +- package-lock.json | 62 +- package.json | 7 +- src/components/Header.astro | 2 +- src/components/Welcome.astro | 12 +- ...rdt-conflict-free-replicated-data-types.md | 64 +- .../fix-adb-unsufficient-permission-linux.md | 7 +- ...nfinix-air-pro-plus-quad-speakers-linux.md | 8 +- .../fix-infinix-air-pro-plus-screen-color.md | 32 +- .../flutter-android-emulator-not-showing.md | 4 +- .../blog/flutter-clean-architecture.md | 821 +++++++++--------- .../blog/kuwot-flutter-daily-quote-app.md | 15 +- .../remap-copilot-key-infinix-air-pro-plus.md | 17 +- src/content/blog/welcome.md | 7 +- src/layouts/Layout.astro | 6 +- src/pages/index.astro | 4 +- src/pages/posts/[id].astro | 4 +- src/pages/posts/index.astro | 4 +- src/pages/tags/[name].astro | 4 +- src/pages/tags/index.astro | 2 +- src/styles/global.css | 10 +- tsconfig.json | 6 +- 22 files changed, 618 insertions(+), 490 deletions(-) diff --git a/astro.config.mjs b/astro.config.mjs index 508cbec..1f02e97 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,11 +1,11 @@ // @ts-check -import { defineConfig } from 'astro/config'; +import { defineConfig } from "astro/config"; -import tailwindcss from '@tailwindcss/vite'; +import tailwindcss from "@tailwindcss/vite"; // https://astro.build/config export default defineConfig({ vite: { - plugins: [tailwindcss()] - } -}); \ No newline at end of file + plugins: [tailwindcss()], + }, +}); diff --git a/package-lock.json b/package-lock.json index 28c3a0d..91e5313 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "tailwindcss": "^4.1.18" }, "devDependencies": { - "@tailwindcss/typography": "^0.5.19" + "@tailwindcss/typography": "^0.5.19", + "prettier": "^3.8.1", + "prettier-plugin-astro": "^0.14.1" } }, "node_modules/@astrojs/compiler": { @@ -4438,6 +4440,37 @@ "node": ">=4" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-astro": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz", + "integrity": "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.9.1", + "prettier": "^3.0.0", + "sass-formatter": "^0.7.6" + }, + "engines": { + "node": "^14.15.0 || >=16.0.0" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -4761,6 +4794,23 @@ "fsevents": "~2.3.2" } }, + "node_modules/s.color": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/s.color/-/s.color-0.0.15.tgz", + "integrity": "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass-formatter": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/sass-formatter/-/sass-formatter-0.7.9.tgz", + "integrity": "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "suf-log": "^2.5.3" + } + }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", @@ -4926,6 +4976,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/suf-log": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz", + "integrity": "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow==", + "dev": true, + "license": "MIT", + "dependencies": { + "s.color": "0.0.15" + } + }, "node_modules/svgo": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", diff --git a/package.json b/package.json index d124c02..dfd6a97 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "format": "prettier --write ." }, "dependencies": { "@tailwindcss/vite": "^4.1.18", @@ -14,6 +15,8 @@ "tailwindcss": "^4.1.18" }, "devDependencies": { - "@tailwindcss/typography": "^0.5.19" + "@tailwindcss/typography": "^0.5.19", + "prettier": "^3.8.1", + "prettier-plugin-astro": "^0.14.1" } } diff --git a/src/components/Header.astro b/src/components/Header.astro index 0b7452f..43dea26 100644 --- a/src/components/Header.astro +++ b/src/components/Header.astro @@ -1,5 +1,5 @@ --- -import NavLink from "./NavLink.astro"; +import NavLink from "@/components/NavLink.astro"; ---
diff --git a/src/components/Welcome.astro b/src/components/Welcome.astro index c571b79..c3fb7ba 100644 --- a/src/components/Welcome.astro +++ b/src/components/Welcome.astro @@ -1,11 +1,11 @@ --- import { Image } from 'astro:assets'; -import profileImage from "../assets/images/profile.jpg"; -import socialEmail from "../assets/images/social-email.svg"; -import socialFacebook from "../assets/images/social-facebook.svg"; -import socialGithub from "../assets/images/social-github.svg"; -import socialLinkedIn from "../assets/images/social-linkedin.svg"; -import socialX from "../assets/images/social-x.svg"; +import profileImage from "@/assets/images/profile.jpg"; +import socialEmail from "@/assets/images/social-email.svg"; +import socialFacebook from "@/assets/images/social-facebook.svg"; +import socialGithub from "@/assets/images/social-github.svg"; +import socialLinkedIn from "@/assets/images/social-linkedin.svg"; +import socialX from "@/assets/images/social-x.svg"; ---
diff --git a/src/content/blog/crdt-conflict-free-replicated-data-types.md b/src/content/blog/crdt-conflict-free-replicated-data-types.md index 16a1c7b..7be2fd5 100644 --- a/src/content/blog/crdt-conflict-free-replicated-data-types.md +++ b/src/content/blog/crdt-conflict-free-replicated-data-types.md @@ -1,6 +1,6 @@ --- -title: 'CRDT - Conflict Free Replicated Data Types' -description: 'A brief intro to Conflict Free Replicated Data Types' +title: "CRDT - Conflict Free Replicated Data Types" +description: "A brief intro to Conflict Free Replicated Data Types" date: 2025-02-24T09:58:08+07:00 draft: false tags: @@ -10,6 +10,7 @@ tags: --- # Background + When searching techniques for syncing data between peers, I stumbled upon CRDT (Conflict-free Replicated Data Types). It's basically a algorithm for syncing for distributed systems. CRDT ensures all data changes between peer will be synced with correct order and no data loss. Since I working with Dart (for Flutter project), I use a [CRDT library for Dart](https://github.com/cachapa/crdt). This library implements core concept of CRDT and it's pretty basic. Here some types of CRDT that often used: @@ -21,63 +22,75 @@ Since I working with Dart (for Flutter project), I use a [CRDT library for Dart] 5. **OR-Set (Observed Remove Set)**: A set that allows elements to be added and removed, using unique identifiers to track additions and removals. 6. **LWW-Register (Last Write Wins Register)**: A register that stores the last written value, using a timestamp to determine the most recent update. 7. **MV-Register (Multi-Value Register)**: A register that stores all values that have been written, using unique identifiers to track writes. + # How it works -- basic version + Main components: + - HLC (Hardware Logical Clock). Combines _wall clock/local time_, a counter that increments, and an optional _node ID_ for uniqueness sake. - The data itself (usually contains a key, value, and the HLC object). + ## **Scenario: Two Devices Synchronizing Data** + We have two devices, **Device A** and **Device B**, which both maintain their own local datasets. Each device can modify data independently. When they synchronize, their CRDT implementations will merge their changes and resolve conflicts. + ## Initial State + - Both devices start with the same data: | Key | Value | isDeleted | Last Modified | | --- | ----- | --------- | ------------- | -| 1 | Alice | false | HLC: A1 | -| 2 | Bob | false | HLC: A2 | +| 1 | Alice | false | HLC: A1 | +| 2 | Bob | false | HLC: A2 | - Device A's last modified HLC: `A2`. - Device B's last modified HLC: `A2`. --- + ## Changes Made on Each Device + 1. **Device A deletes Bob's record (key 2):** | Key | Value | isDeleted | Last Modified | | --- | ----- | --------- | ------------- | -| 2 | null | true | HLC: A3 | +| 2 | null | true | HLC: A3 | 2. **Device B updates Alice's name to Alice Smith (key 1):** -| Key | Value | isDeleted | Last Modified | -| --- | ----- | --------- | ------------- | -| 1 | Alice Smith | false | HLC: B3 | +| Key | Value | isDeleted | Last Modified | +| --- | ----------- | --------- | ------------- | +| 1 | Alice Smith | false | HLC: B3 | --- + ## Synchronization and Merge + - Device A sends its **changeset** to Device B: | Key | Value | isDeleted | Last Modified | | --- | ----- | --------- | ------------- | -| 2 | null | true | HLC: A3 | +| 2 | null | true | HLC: A3 | - Device B sends its **changeset** to Device A: -| Key | Value | isDeleted | Last Modified | -| --- | ----- | --------- | ------------- | -| 1 | Alice Smith | false | HLC: B3 | +| Key | Value | isDeleted | Last Modified | +| --- | ----------- | --------- | ------------- | +| 1 | Alice Smith | false | HLC: B3 | --- + ## Step-by-Step Conflict Resolution The `merge` method processes these changes: + 1. **Validate Changeset**: - Each incoming record is validated to ensure it matches the expected schema and contains valid HLC timestamps. - + Each incoming record is validated to ensure it matches the expected schema and contains valid HLC timestamps. 2. **Compare Records for Key 1 (`Alice`)**: | Key | Value | isDeleted | Last Modified | | --- | ----- | --------- | ------------- | -| 1 | Alice | false | HLC: A1 | +| 1 | Alice | false | HLC: A1 | Incoming record from Device B: | Key | Value | isDeleted | Last Modified | @@ -86,15 +99,15 @@ Incoming record from Device B: **Conflict Resolution Rule**: The record with the higher `Last Modified` HLC wins. HLC `B3 > A1,` so Device A updates Alice's record to: -| Key | Value | isDeleted | Last Modified | -| --- | ----- | --------- | ------------- | -| 1 | Alice Smith | false | HLC: B3 | +| Key | Value | isDeleted | Last Modified | +| --- | ----------- | --------- | ------------- | +| 1 | Alice Smith | false | HLC: B3 | 3. **Compare Records for Key 2 (`Bob`)**: | Key | Value | isDeleted | Last Modified | | --- | ----- | --------- | ------------- | -| 2 | Bob | false | HLC: A2 | +| 2 | Bob | false | HLC: A2 | Incoming record from Device A: | Key | Value | isDeleted | Last Modified | @@ -105,20 +118,22 @@ Incoming record from Device A: | Key | Value | isDeleted | Last Modified | | --- | ----- | --------- | ------------- | -| 2 | null | true | HLC: A3 | +| 2 | null | true | HLC: A3 | 4. **Propagate Changes**: Both devices now have identical datasets after merging. --- + ## Final Merged Dataset on Both Devices -| Key | Value | isDeleted | Last Modified | -| --- | ----- | --------- | ------------- | -| 1 | Alice Smith | false | HLC: B3 | -| 2 | null | true | HLC: A3 | +| Key | Value | isDeleted | Last Modified | +| --- | ----------- | --------- | ------------- | +| 1 | Alice Smith | false | HLC: B3 | +| 2 | null | true | HLC: A3 | --- + ## Summary of Conflict Resolution Rules 1. **Higher HLC Wins** @@ -127,4 +142,3 @@ Incoming record from Device A: A `null` value with `isDeleted: true` is treated as a soft delete. It wins if its HLC is higher. 3. **Deterministic Behavior** All nodes independently apply the same conflict resolution logic, ensuring eventual consistency. - diff --git a/src/content/blog/fix-adb-unsufficient-permission-linux.md b/src/content/blog/fix-adb-unsufficient-permission-linux.md index 307ab6b..df0d833 100644 --- a/src/content/blog/fix-adb-unsufficient-permission-linux.md +++ b/src/content/blog/fix-adb-unsufficient-permission-linux.md @@ -1,6 +1,6 @@ --- -title: 'Fix ADB Insufficient Permission' -description: 'udev rules for fixing ADB Insufficient permission in Linux' +title: "Fix ADB Insufficient Permission" +description: "udev rules for fixing ADB Insufficient permission in Linux" date: 2025-02-24T11:29:30+07:00 draft: false tags: @@ -10,6 +10,7 @@ tags: --- # Why need this? + Connecting an Android device to a Linux computer could has problem with permission. Listing devices with `adb devices` might show `unauthorized` status or `insufficient permission` error message. ```bash @@ -33,7 +34,7 @@ $ lsusb Bus 001 Device 005: ID 18d1:4ee7 Google Inc. Nexus/Pixel Device ``` -`18d1` is *vendor id* and `4ee7` is the *product id*. +`18d1` is _vendor id_ and `4ee7` is the _product id_. 2. Create an `udev` rule for this device, I create mine in `/etc/udev/rules.d/51-android.rules`. diff --git a/src/content/blog/fix-infinix-air-pro-plus-quad-speakers-linux.md b/src/content/blog/fix-infinix-air-pro-plus-quad-speakers-linux.md index c791825..5c564b9 100644 --- a/src/content/blog/fix-infinix-air-pro-plus-quad-speakers-linux.md +++ b/src/content/blog/fix-infinix-air-pro-plus-quad-speakers-linux.md @@ -1,6 +1,6 @@ --- -title: 'Fix Infinix Air Pro+ Quad Speakers in Linux' -description: 'A fix for Infinix Air Pro+ -- only 2 of 4 speakers working in Linux' +title: "Fix Infinix Air Pro+ Quad Speakers in Linux" +description: "A fix for Infinix Air Pro+ -- only 2 of 4 speakers working in Linux" date: 2025-02-23T23:27:49+07:00 draft: false tags: @@ -83,8 +83,8 @@ $ sudo pacman -S alsa-tools $ sudo hdajackretask ``` -After opening the tool, check the `Show unconnected pins` and there will be list of nodes that can be retasked. -I need to experiment with this remapping. After trials and errors, I found that `0x1a` and `0x1b` is the responsible nodes for my extra speakers. Overriding them and changing their role as `Internal Speaker` solve my issue. +After opening the tool, check the `Show unconnected pins` and there will be list of nodes that can be retasked. +I need to experiment with this remapping. After trials and errors, I found that `0x1a` and `0x1b` is the responsible nodes for my extra speakers. Overriding them and changing their role as `Internal Speaker` solve my issue. ![hdajackretask remap nodes](/images/hdajackretask-remap-node.png) diff --git a/src/content/blog/fix-infinix-air-pro-plus-screen-color.md b/src/content/blog/fix-infinix-air-pro-plus-screen-color.md index c52b93d..24dc5a9 100644 --- a/src/content/blog/fix-infinix-air-pro-plus-screen-color.md +++ b/src/content/blog/fix-infinix-air-pro-plus-screen-color.md @@ -1,6 +1,6 @@ --- -title: 'Fix Infinix Air Pro+ Screen Color' -description: 'Fixing Infinix Air Pro+ washed out screen color in Windows and Linux' +title: "Fix Infinix Air Pro+ Screen Color" +description: "Fixing Infinix Air Pro+ washed out screen color in Windows and Linux" date: 2025-02-21T18:09:54+07:00 draft: false tags: @@ -10,26 +10,29 @@ tags: - script --- -I have Infinix Air Pro+ and I use it for my work. I can say it is a good laptop coding mainly because it has 2.5k OLED 16:10 screen. But I found a problem with its screen color. When the screen brightness is below about 50% and the screen turned off (to save power, not necessarily going system sleep/suspend) and turns back on, the color looks washed out. +I have Infinix Air Pro+ and I use it for my work. I can say it is a good laptop coding mainly because it has 2.5k OLED 16:10 screen. But I found a problem with its screen color. When the screen brightness is below about 50% and the screen turned off (to save power, not necessarily going system sleep/suspend) and turns back on, the color looks washed out. First time I noticed this issue is because I was using a pitch black wallpaper image (so I can flex my OLED display). After my screen turns back on, my wallpaper's black color becomes grainy, washed out, as its doesn't have pitch black color anymore. Then I noticed, the color will be fixed after I crank the brightness to above 50%. Turning the brightness down again after this still gives me correct black level. So, I was wondering if I create a script that will turn the brightness to above 50% and restore it to where it was every time my screen is waking up from a sleep. With a help from Google and ChatGPT, I create these scripts as a workaround for this annoying issue. # Windows + Before continuing, I'm sorry I can't give any screenshot for this Windows section because I already switched to Linux, but I hope I can write it clearly. ## Get screen wake up event -I need to listen to an event that tells me "Hey, the screen is turning on". Fortunately, Windows has [Event Viewer](https://learn.microsoft.com/en-us/shows/inside/event-viewer) that I can use for this. I found that an event from *Kernel-Power* with event ID *507* is the correct event that means the screen in turned back on. + +I need to listen to an event that tells me "Hey, the screen is turning on". Fortunately, Windows has [Event Viewer](https://learn.microsoft.com/en-us/shows/inside/event-viewer) that I can use for this. I found that an event from _Kernel-Power_ with event ID _507_ is the correct event that means the screen in turned back on. ## Script + Next thing to do is create the script to control screen brightness. After trial and error, I found [NirCmd](https://www.nirsoft.net/utils/nircmd.html) can help me to change my screen brightness. Then I create this Powershell script. ```powershell -# Infinix Air Pro Plus suffers from washed out colors -# after the display goes off and back on if the brightness is below 50%. -# This script will increase the brightness to 60% when initial brightness -# is below 50% else it will increase 10% from current brightness and turn +# Infinix Air Pro Plus suffers from washed out colors +# after the display goes off and back on if the brightness is below 50%. +# This script will increase the brightness to 60% when initial brightness +# is below 50% else it will increase 10% from current brightness and turn # back to initial brightness value. # Path to NirCmd executable @@ -64,17 +67,21 @@ if ($currentBrightness -lt 50) { ``` ## Make a schedule -I use Windows' [Task Scheduler](https://www.windowscentral.com/how-create-automated-tasks-windows-11) to run the script each time *Kernel-Power* with event ID *507* occurs. I can't show the step-by-step guide because I'm on Linux now, but I have a backup file for this task. All you need is just to import [this task](/misc/Restore%20OLED%20Colors.xml) in Task Scheduler. + +I use Windows' [Task Scheduler](https://www.windowscentral.com/how-create-automated-tasks-windows-11) to run the script each time _Kernel-Power_ with event ID _507_ occurs. I can't show the step-by-step guide because I'm on Linux now, but I have a backup file for this task. All you need is just to import [this task](/misc/Restore%20OLED%20Colors.xml) in Task Scheduler. > Note: You have to change the command it executes to where you save the Powershell script. Also change the author into `YOUR_PC_NAME\YOUR_USERNAME`. # Linux + I'm using [EndeavourOS](https://endeavouros.com/) which use `systemd`. So this guide is applicable to `systemd` init system only. If your linux use something else, you need to adjust it with your init system. ## Get screen wake up event + I already tried several ways to listen the screen wake up events. But I can't find any using `acpi` and `udev`. So I tried different approach. I check `dpms` property from screen device in `/sys/class/drm/card1-eDP-1/dpms`. It has `On` and `Off` value that I can use for triggering a script to fix the color. ## Script + I have 2 scripts for this approach. One for checking `/sys/class/drm/card1-eDP-1/dpms` value and another one for fixing the color. ```bash @@ -86,7 +93,7 @@ prev_state="" while true; do state=$(cat /sys/class/drm/card1-eDP-1/dpms) - + if [[ "$state" != "$prev_state" && "$state" == "On" ]]; then echo "Screen turned on! Running script..." /usr/local/bin/brightness_fix.sh @@ -137,6 +144,7 @@ fi ``` ## Make a systemd service + Make a `systemd` service in `/etc/systemd/system/brightness-fix.service` to run the first script. ```plaintext @@ -171,10 +179,10 @@ WantedBy=suspend.target Then register, enable, and start it. ```bash -sudo systemctl daemon-reload +sudo systemctl daemon-reload sudo systemctl enable brightness-fix.service sudo systemctl enable brightness-fix-wakeup.service sudo systemctl start brightness-fix.service ``` -One more thing, you can add also `/usr/local/bin/brightness_fix.sh` to autostart (I'm using KDE) so it will run each time you login. \ No newline at end of file +One more thing, you can add also `/usr/local/bin/brightness_fix.sh` to autostart (I'm using KDE) so it will run each time you login. diff --git a/src/content/blog/flutter-android-emulator-not-showing.md b/src/content/blog/flutter-android-emulator-not-showing.md index d77f3ef..c21c1ca 100644 --- a/src/content/blog/flutter-android-emulator-not-showing.md +++ b/src/content/blog/flutter-android-emulator-not-showing.md @@ -1,5 +1,5 @@ --- -title: 'Android Devices Not Showing on Flutter Project' +title: "Android Devices Not Showing on Flutter Project" description: "A fix for new Flutter project that doesn't have Android devices showing up" date: 2025-02-22T09:21:22+07:00 draft: false @@ -22,4 +22,4 @@ As you can see, I have no Android SDK selected for my Flutter project. So, go ah ![choose android sdk](/images/choose-sdk.webp) -Hope this can help someone who has similar issues with Flutter project and Android devices. \ No newline at end of file +Hope this can help someone who has similar issues with Flutter project and Android devices. diff --git a/src/content/blog/flutter-clean-architecture.md b/src/content/blog/flutter-clean-architecture.md index 8769b2a..b1b87e8 100644 --- a/src/content/blog/flutter-clean-architecture.md +++ b/src/content/blog/flutter-clean-architecture.md @@ -1,8 +1,8 @@ --- -title: 'Flutter Clean Architecture' +title: "Flutter Clean Architecture" description: Dive into Clean Architecture for Flutter or Dart projects images: -- /images/opengraph.png + - /images/opengraph.png date: 2025-01-12T00:12:04+07:00 draft: false tags: @@ -13,14 +13,17 @@ tags: ![flutter-clean-architecture](/images/flutter_dash.png) # What is Clean Architecture? -Do you ever wondering how to manage your Flutter code? How to make it neat, modular, easy to maintain and test? Here where *clean architecture* comes in. + +Do you ever wondering how to manage your Flutter code? How to make it neat, modular, easy to maintain and test? Here where _clean architecture_ comes in. Basically, clean architecture is a way to organize your code into separated pieces that will make your project cleaner. It may looks complicated at first and a lot of boiler code for some reasons. But trust me, it will be a lot easier if you apply the clean architecture in your code, especially in medium to bigger projects. In this set of Clean Architecture articles, we will create a basic mobile app that uses [WeatherAPI](https://www.weatherapi.com/) to get current weather. Let's get started! > Please note that this guide requires basic knowledge of Dart and Flutter. So I don't recommend going through this guide if you are completely new to the topic. + # Directory Structure + I use this directory structure to organize my code into clean architecture. Once you got the idea, you may modify the structure to match your needs. ``` @@ -57,9 +60,13 @@ your-flutter-project-dir ``` --- + ## Core + You'll stores all reusable code inside `core`. Things like abstract classes (maybe a model base, error base, etc), or maybe a base widgets, snackbars, dialogs, also your app router, anything that you need to access across your app are best to keep inside `core` directory. + ### Core - Data + `core/data` stores base classes related to your data. Divided into `local` for locally-stored data (ex: configs, persistence, cache), and `remote` for data from external sources (ex: web API). Let's create a local `config.dart` base class to store app configuration using [shared_preferences](https://pub.dev/packages/shared_preferences) . @@ -179,12 +186,14 @@ class WeatherApiErrorModel { _$WeatherApiErrorModelFromJson(json); } ``` + ### Core - Domain -`core/domain` contains *use case* base class. If you unfamiliar with a *use case* (also called *unit-of-work*), it's a **single-purpose** class that has a method `execute/call` to do particular function in your app. We'll find out how it works in several sections ahead. + +`core/domain` contains _use case_ base class. If you unfamiliar with a _use case_ (also called _unit-of-work_), it's a **single-purpose** class that has a method `execute/call` to do particular function in your app. We'll find out how it works in several sections ahead. In this class we use [fpdart](https://pub.dev/packages/fpdart)'s `Either` class. In [Functional Programming](), `Either` means a function that will return a `Right` value for positive/success scenario, or `Left` when it fails. You can read about it in the previous links. -I'll try to explain briefly, `use_case.dart` below has 2 generics. `Type` is a return type when the *use case* is succesfully executed, and `Params` contains parameters that are required to execute the *use case*. Then in `call` method it has return type of `Either`. It means this method will returns `Type` if success, and `Failure` when things got ugly. +I'll try to explain briefly, `use_case.dart` below has 2 generics. `Type` is a return type when the _use case_ is succesfully executed, and `Params` contains parameters that are required to execute the _use case_. Then in `call` method it has return type of `Either`. It means this method will returns `Type` if success, and `Failure` when things got ugly. ```dart // lib/core/domain/use_case.dart @@ -199,7 +208,9 @@ abstract class UseCase { Future> call(Params params); } ``` + ### Core - Error + We'll use `core/error` dir to stores `Failure` classes. `Failure` used when the app throws errors and exceptions. It's like having a custom exception class. ```dart @@ -245,151 +256,163 @@ Then we'll add some custom exceptions to handle different exceptions that might ```dart // lib/core/error/exceptions.dart -/// Exception class for server error -/// Generally, this exception is thrown when the server returns an error response -class ServerException implements Exception { - const ServerException(this.message); - - final String message; -} - -/// Exception class for unauthorized client error -/// this exception is thrown when the client is not authorized -/// to access the resource (server returns 401) -class UnauthorizedException implements Exception { +/// Exception class for server error +/// Generally, this exception is thrown when the server returns an error response +class ServerException implements Exception { + const ServerException(this.message); + + final String message; +} + +/// Exception class for unauthorized client error +/// this exception is thrown when the client is not authorized +/// to access the resource (server returns 401) +class UnauthorizedException implements Exception { const UnauthorizedException(this.message); - + final String message; } ``` + ### Core - Network + We will need a HTTP client to get data from [WeatherAPI](https://www.weatherapi.com/). I'll use [http](https://pub.dev/packages/http) package, but you can also use [dio](https://pub.dev/packages/dio) or another similar packages. ```dart // lib/core/network/network.dart -import 'dart:convert'; -import 'dart:io'; -import 'package:clean_architecture/core/error/exceptions.dart'; -import 'package:http/http.dart' as http; - -/// Network interface -abstract class Network { - /// Get data from uri - Future get( - Uri uri, { - Map? headers, - }); -} +import 'dart:convert'; +import 'dart:io'; +import 'package:clean_architecture/core/error/exceptions.dart'; +import 'package:http/http.dart' as http; -/// Network implementation -class NetworkImpl implements Network { - NetworkImpl(http.Client httpClient) : _httpClient = httpClient; - - final http.Client _httpClient; - - @override - Future get( - Uri uri, { - Map? headers, - }) async { - final response = await _httpClient.get( - uri, - headers: headers, - ); final stringResponse = utf8.decode(response.bodyBytes); - - if (response.statusCode == HttpStatus.unauthorized) { - throw UnauthorizedException(stringResponse); - } - if (response.statusCode != HttpStatus.ok) { - throw ServerException(stringResponse); - } - return stringResponse; +/// Network interface +abstract class Network { + /// Get data from uri + Future get( + Uri uri, { + Map? headers, + }); +} + +/// Network implementation +class NetworkImpl implements Network { + NetworkImpl(http.Client httpClient) : _httpClient = httpClient; + + final http.Client _httpClient; + + @override + Future get( + Uri uri, { + Map? headers, + }) async { + final response = await _httpClient.get( + uri, + headers: headers, + ); final stringResponse = utf8.decode(response.bodyBytes); + + if (response.statusCode == HttpStatus.unauthorized) { + throw UnauthorizedException(stringResponse); + } + if (response.statusCode != HttpStatus.ok) { + throw ServerException(stringResponse); + } + return stringResponse; } } ``` -If you are still new in programming, you may wonder: *Why I should create an abstract class here? It will be okay with a concrete Network class without inheritance*. I'll explain it later, but for now is enough for you to know that this abstract class will be used as a *mock* in testing. +If you are still new in programming, you may wonder: _Why I should create an abstract class here? It will be okay with a concrete Network class without inheritance_. I'll explain it later, but for now is enough for you to know that this abstract class will be used as a _mock_ in testing. + ### Core - Presentation + `core/presentation` contains UI widgets and other presentation related classes that will be used across your app. We can also have a UI-related business logic that will be used across the app. Since our app will have theme mode switching feature, we will add a `cubit` to do the theme mode switch here. ```dart -// lib/core/presentation/theme/app_theme.dart +// lib/core/presentation/theme/app_theme.dart -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; - -/// App light theme -ThemeData lightTheme = ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6F43C0), - ), useMaterial3: true, - fontFamily: GoogleFonts.dmSans().fontFamily, -); - -/// App dark theme -ThemeData darkTheme = ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xFF6F43C0), - brightness: Brightness.dark, - ), useMaterial3: true, - fontFamily: GoogleFonts.dmSans().fontFamily, +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// App light theme +ThemeData lightTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6F43C0), + ), useMaterial3: true, + fontFamily: GoogleFonts.dmSans().fontFamily, +); + +/// App dark theme +ThemeData darkTheme = ThemeData( + colorScheme: ColorScheme.fromSeed( + seedColor: const Color(0xFF6F43C0), + brightness: Brightness.dark, + ), useMaterial3: true, + fontFamily: GoogleFonts.dmSans().fontFamily, ); ``` ```dart // lib/core/presentation/theme/theme_mode_cubit.dart -import 'package:clean_architecture/core/data/local/config.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -/// Theme mode cubit for theme mode management -class ThemeModeCubit extends Cubit { - /// Default [ThemeMode] is [ThemeMode.system] - ThemeModeCubit({ - required this.themeModeConfig, - required this.initialThemeMode, - }) : super(initialThemeMode); - - /// Theme mode config - final Config themeModeConfig; - - /// Initial theme mode - final ThemeMode initialThemeMode; - - /// Set theme mode - void setThemeMode(ThemeMode themeMode) { - themeModeConfig.set(themeMode); - emit(themeMode); +import 'package:clean_architecture/core/data/local/config.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Theme mode cubit for theme mode management +class ThemeModeCubit extends Cubit { + /// Default [ThemeMode] is [ThemeMode.system] + ThemeModeCubit({ + required this.themeModeConfig, + required this.initialThemeMode, + }) : super(initialThemeMode); + + /// Theme mode config + final Config themeModeConfig; + + /// Initial theme mode + final ThemeMode initialThemeMode; + + /// Set theme mode + void setThemeMode(ThemeMode themeMode) { + themeModeConfig.set(themeMode); + emit(themeMode); }} ``` + ### Core - Routes + There is a package called [auto_route](https://pub.dev/packages/auto_route) that will ease you to manage routes in your app yet keep your code clean. Using the guide from their package page, we'll have `app_router.dart` inside `core/routes` directory. Since we don't have any page to route to yet, just leave it empty. + ### Env File + Storing secret directly in the code is a bad practice and we shouldn't do that. There are many ways to hardcode it into the code and one of them is to have an `env` file. You can read more about it [here](https://dart.dev/libraries/core/environment-declarations). ```dart // lib/core/env.dart -abstract class Env { - String get weatherApiHost; - String get weatherApiKey; -} - -class EnvImpl implements Env { - @override - String get weatherApiHost => const String.fromEnvironment('WEATHER_API_HOST'); - - @override - String get weatherApiKey => const String.fromEnvironment('WEATHER_API_KEY'); +abstract class Env { + String get weatherApiHost; + String get weatherApiKey; +} + +class EnvImpl implements Env { + @override + String get weatherApiHost => const String.fromEnvironment('WEATHER_API_HOST'); + + @override + String get weatherApiKey => const String.fromEnvironment('WEATHER_API_KEY'); } ``` --- + ### Feature + In clean architecture, we divide our application into **features**. For example, in this project will have a **weather** feature. Each feature will have its own (but not always neccessarily) `domain`, `data` and `presentation`. + ### Feature - Data + `data` as it's namesake, will deals with all data needed by the app. It contains (not limited to) `model`, `repository` implementation, and `data sources`. `Model` and `repository` classes are self-explanatory, and `data sources` will be used to access data from both local and remote sources. Let's create a model for [WeatherAPI](https://www.weatherapi.com/) current weather response. @@ -469,42 +492,44 @@ The next part is create the `data_source`, which will be responsible to access d ```dart // lib/features/weather/data/data_sources/remote/weather_api_remote_data_source.dart -import 'dart:convert'; - -import 'package:clean_architecture/core/env.dart'; -import 'package:clean_architecture/core/network/network.dart'; -import 'package:clean_architecture/features/weather/data/models/current_weather_model.dart'; - -abstract class WeatherApiRemoteDataSource { - Future getCurrentWeather(String city); -} - -class WeatherApiRemoteDataSourceImpl implements WeatherApiRemoteDataSource { - final Env env; - final Network network; - - WeatherApiRemoteDataSourceImpl({ - required this.env, - required this.network, - }); +import 'dart:convert'; + +import 'package:clean_architecture/core/env.dart'; +import 'package:clean_architecture/core/network/network.dart'; +import 'package:clean_architecture/features/weather/data/models/current_weather_model.dart'; + +abstract class WeatherApiRemoteDataSource { + Future getCurrentWeather(String city); +} + +class WeatherApiRemoteDataSourceImpl implements WeatherApiRemoteDataSource { + final Env env; + final Network network; + + WeatherApiRemoteDataSourceImpl({ + required this.env, + required this.network, + }); @override - Future getCurrentWeather(String city) async { - final uri = Uri( - scheme: 'https', - host: env.weatherApiHost, - path: 'v1/current.json', - queryParameters: { - 'key': env.weatherApiKey, - 'q': city, + Future getCurrentWeather(String city) async { + final uri = Uri( + scheme: 'https', + host: env.weatherApiHost, + path: 'v1/current.json', + queryParameters: { + 'key': env.weatherApiKey, + 'q': city, }, ); - final response = await network.get(uri); - final jsonResponse = jsonDecode(response) as Map; - return CurrentWeatherModel.fromJson(jsonResponse); + final response = await network.get(uri); + final jsonResponse = jsonDecode(response) as Map; + return CurrentWeatherModel.fromJson(jsonResponse); } } ``` + ### Feature - Domain + `domain` stores `entities`, `use cases` and `abstract repository` classes, as they are the ‘domain’ or ‘subject’ area of an application. If you aren’t familiar with the term, you can think that this ‘domain’ is the base requirement of an application. First thing, we need to create an `entity` for current weather data. This entity will represent what kind of data we want to show to the user. @@ -654,7 +679,9 @@ class WeatherApiRepositoryImpl implements WeatherApiRepository { } } ``` + ### Feature - Presentation + `presentation` stores **pages** and **widgets**. These are the 'presentation' or 'view' area of the application. If you aren't familiar with the term, you can think that this 'presentation' is the actual view of an application. In this `presentation` layer, we use [auto_route](https://pub.dev/packages/auto_route) package to manage our pages routing. Then [flutter_bloc](https://pub.dev/packages/flutter_bloc) package will help us to manage state management hence keeping our code clean because we will separate the logic from the UI. @@ -790,162 +817,162 @@ The next part is to create weather page in `presentation` directory. ```dart // lib/features/weather/presentation/current_weather_page.dart -import 'package:auto_route/auto_route.dart'; -import 'package:clean_architecture/core/router/app_router.gr.dart'; -import 'package:clean_architecture/features/weather/presentation/bloc/current_weather_bloc.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -@RoutePage() -class CurrentWeatherPage extends StatefulWidget { - const CurrentWeatherPage({super.key}); - - @override - State createState() => _CurrentWeatherPageState(); -} - -class _CurrentWeatherPageState extends State { - final _cityTextCtl = TextEditingController(); - final _cityTextFocus = FocusNode(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Current Weather'), - actions: [ - IconButton( - icon: const Icon(Icons.settings), - onPressed: () { - context.router.push(const AppSettingsRoute()); +import 'package:auto_route/auto_route.dart'; +import 'package:clean_architecture/core/router/app_router.gr.dart'; +import 'package:clean_architecture/features/weather/presentation/bloc/current_weather_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +@RoutePage() +class CurrentWeatherPage extends StatefulWidget { + const CurrentWeatherPage({super.key}); + + @override + State createState() => _CurrentWeatherPageState(); +} + +class _CurrentWeatherPageState extends State { + final _cityTextCtl = TextEditingController(); + final _cityTextFocus = FocusNode(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Current Weather'), + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + context.router.push(const AppSettingsRoute()); }, ), ], - ), - body: BlocBuilder( - builder: (context, state) { - return ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - TextField( - controller: _cityTextCtl, - focusNode: _cityTextFocus, - decoration: const InputDecoration( - hintText: 'City', + ), + body: BlocBuilder( + builder: (context, state) { + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + TextField( + controller: _cityTextCtl, + focusNode: _cityTextFocus, + decoration: const InputDecoration( + hintText: 'City', ), ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () { - context.read().add( - GetCurrentWeatherEvent( - city: _cityTextCtl.text, + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + context.read().add( + GetCurrentWeatherEvent( + city: _cityTextCtl.text, ), ); }, - child: const Text('Get Weather'), + child: const Text('Get Weather'), ), - const SizedBox(height: 16), - _buildWeather(state), + const SizedBox(height: 16), + _buildWeather(state), ], ); }, - ), - ); - } - - Widget _buildWeather(CurrentWeatherState state) { - if (state is CurrentWeatherLoadedState) { - _cityTextFocus.unfocus(); - - final weatherIconUrl = - 'https:${state.currentWeather.conditionIcon ?? '//placehold.co/64x64/png'}'; - - return Column( - children: [ - Image.network(weatherIconUrl), - Text( - state.currentWeather.conditionText ?? '-', - style: Theme.of(context).textTheme.headlineSmall, + ), + ); + } + + Widget _buildWeather(CurrentWeatherState state) { + if (state is CurrentWeatherLoadedState) { + _cityTextFocus.unfocus(); + + final weatherIconUrl = + 'https:${state.currentWeather.conditionIcon ?? '//placehold.co/64x64/png'}'; + + return Column( + children: [ + Image.network(weatherIconUrl), + Text( + state.currentWeather.conditionText ?? '-', + style: Theme.of(context).textTheme.headlineSmall, ), - Text( - '${state.currentWeather.locationName}, ${state.currentWeather.locationRegion}'), - Text('${state.currentWeather.locationCountry}'), - const SizedBox(height: 16), - GridView.count( - crossAxisCount: 3, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - children: [ - _buildDataCard( - 'Temp (C)', - '${state.currentWeather.tempC ?? '-'}', - ), - _buildDataCard( - 'Feels Like (C)', - '${state.currentWeather.feelslikeC ?? '-'}', - ), - _buildDataCard( - 'Wind (km/h)', - '${state.currentWeather.windKph ?? '-'}', - ), - _buildDataCard( - 'Wind Dir', - state.currentWeather.windDir, - ), - _buildDataCard( - 'Precip (mm)', - '${state.currentWeather.precipMm ?? '-'}', - ), - _buildDataCard( - 'Humidity (%)', - '${state.currentWeather.humidity ?? '-'}', - ), - _buildDataCard( - 'Cloud (%)', - '${state.currentWeather.cloud ?? '-'}', - ), - _buildDataCard( - 'Vis (km)', - '${state.currentWeather.visKm ?? '-'}', - ), - _buildDataCard( - 'UV', - '${state.currentWeather.uv ?? '-'}', + Text( + '${state.currentWeather.locationName}, ${state.currentWeather.locationRegion}'), + Text('${state.currentWeather.locationCountry}'), + const SizedBox(height: 16), + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + _buildDataCard( + 'Temp (C)', + '${state.currentWeather.tempC ?? '-'}', + ), + _buildDataCard( + 'Feels Like (C)', + '${state.currentWeather.feelslikeC ?? '-'}', + ), + _buildDataCard( + 'Wind (km/h)', + '${state.currentWeather.windKph ?? '-'}', + ), + _buildDataCard( + 'Wind Dir', + state.currentWeather.windDir, + ), + _buildDataCard( + 'Precip (mm)', + '${state.currentWeather.precipMm ?? '-'}', + ), + _buildDataCard( + 'Humidity (%)', + '${state.currentWeather.humidity ?? '-'}', + ), + _buildDataCard( + 'Cloud (%)', + '${state.currentWeather.cloud ?? '-'}', + ), + _buildDataCard( + 'Vis (km)', + '${state.currentWeather.visKm ?? '-'}', + ), + _buildDataCard( + 'UV', + '${state.currentWeather.uv ?? '-'}', ), ], - ), - const SizedBox(height: 16), - Text( - 'Last Updated: ${state.currentWeather.lastUpdated}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ); - } - if (state is CurrentWeatherLoadingState) { - return const Center(child: CircularProgressIndicator()); - } - if (state is CurrentWeatherErrorState) { - return Text(state.message); - } - return const SizedBox(); - } - Widget _buildDataCard(String header, String? content) { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(header, textAlign: TextAlign.center), - Text( - content ?? '-', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineLarge, - ), - ], - ), - ); + ), + const SizedBox(height: 16), + Text( + 'Last Updated: ${state.currentWeather.lastUpdated}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + if (state is CurrentWeatherLoadingState) { + return const Center(child: CircularProgressIndicator()); + } + if (state is CurrentWeatherErrorState) { + return Text(state.message); + } + return const SizedBox(); + } + Widget _buildDataCard(String header, String? content) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(header, textAlign: TextAlign.center), + Text( + content ?? '-', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineLarge, + ), + ], + ), + ); } } ``` @@ -955,56 +982,56 @@ and we also need a page to change application configurations (for now we only ha ```dart // lib/features/app_settings/presentation/app_settings_page.dart -import 'package:auto_route/auto_route.dart'; -import 'package:clean_architecture/core/presentation/theme/theme_mode_cubit.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -@RoutePage() -class AppSettingsPage extends StatefulWidget { - const AppSettingsPage({super.key}); - - @override - State createState() => _AppSettingsPageState(); -} - -class _AppSettingsPageState extends State { - @override - Widget build(BuildContext context) { - final themeSetting = Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('App Theme'), - DropdownButton( - items: const [ - DropdownMenuItem( - value: ThemeMode.system, - child: Text('System'), - ), DropdownMenuItem( - value: ThemeMode.light, - child: Text('Light'), - ), DropdownMenuItem( - value: ThemeMode.dark, - child: Text('Dark'), +import 'package:auto_route/auto_route.dart'; +import 'package:clean_architecture/core/presentation/theme/theme_mode_cubit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +@RoutePage() +class AppSettingsPage extends StatefulWidget { + const AppSettingsPage({super.key}); + + @override + State createState() => _AppSettingsPageState(); +} + +class _AppSettingsPageState extends State { + @override + Widget build(BuildContext context) { + final themeSetting = Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('App Theme'), + DropdownButton( + items: const [ + DropdownMenuItem( + value: ThemeMode.system, + child: Text('System'), + ), DropdownMenuItem( + value: ThemeMode.light, + child: Text('Light'), + ), DropdownMenuItem( + value: ThemeMode.dark, + child: Text('Dark'), ), ], - value: context.watch().state, - onChanged: (value) { - context.read().setThemeMode(value!); + value: context.watch().state, + onChanged: (value) { + context.read().setThemeMode(value!); }, ), ], - ); - return Scaffold( - appBar: AppBar( - title: const Text('App Settings'), + ); + return Scaffold( + appBar: AppBar( + title: const Text('App Settings'), ), - body: ListView( - padding: const EdgeInsets.symmetric(horizontal: 16), - children: [ - themeSetting, - ], - ), + body: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + themeSetting, + ], + ), ); } } @@ -1015,24 +1042,26 @@ As for the routing I mentioned in the previously, we will create an `app_router. ```dart // lib/core/router/app_router.dart -import 'package:auto_route/auto_route.dart'; -import 'package:clean_architecture/core/router/app_router.gr.dart'; - -@AutoRouterConfig() -class AppRouter extends RootStackRouter { - @override - List get routes => [ - AutoRoute( - page: CurrentWeatherRoute.page, - initial: true, - ), - AutoRoute(page: AppSettingsRoute.page), +import 'package:auto_route/auto_route.dart'; +import 'package:clean_architecture/core/router/app_router.gr.dart'; + +@AutoRouterConfig() +class AppRouter extends RootStackRouter { + @override + List get routes => [ + AutoRoute( + page: CurrentWeatherRoute.page, + initial: true, + ), + AutoRoute(page: AppSettingsRoute.page), ]; } ``` --- + ## Dependency Injection + In clean architecture, we use **dependency injection** (or DI in short) to make our project cleaner. In a traditional way of creating an instance, we need to use **contructor injection** as we pass the required parameters to the constructor. It will make a mess if we are creating many instances throughout the project, because it will scattered anywhere. There are many ways to achieve dependency injection in Flutter, for this project I will use [GetIt](https://pub.dev/packages/get_it). Let's create our `injection_container`, this class is responsible for creating all the instances that we need in our project. @@ -1128,21 +1157,21 @@ To monitor events and states in our bloc, also add a `bloc observer` class. ```dart // lib/core/presentation/bloc/app_bloc_observer.dart -import 'dart:developer' as dev; - -import 'package:flutter_bloc/flutter_bloc.dart'; - -class AppBlocObserver extends BlocObserver { - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - dev.log("[bloc_error] $bloc\nerror: $error\nstacktrace: $stackTrace"); - super.onError(bloc, error, stackTrace); - } - @override - void onChange(BlocBase bloc, Change change) { - dev.log( - "[${bloc.runtimeType}] ${DateTime.now().toIso8601String()}\nFrom: ${change.currentState}\nNext: ${change.nextState}"); - super.onChange(bloc, change); +import 'dart:developer' as dev; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +class AppBlocObserver extends BlocObserver { + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + dev.log("[bloc_error] $bloc\nerror: $error\nstacktrace: $stackTrace"); + super.onError(bloc, error, stackTrace); + } + @override + void onChange(BlocBase bloc, Change change) { + dev.log( + "[${bloc.runtimeType}] ${DateTime.now().toIso8601String()}\nFrom: ${change.currentState}\nNext: ${change.nextState}"); + super.onChange(bloc, change); } } ``` @@ -1152,65 +1181,67 @@ Now we have all the required components for our app, lets look at the `main.dart ```dart // lib/main.dart -import 'package:clean_architecture/core/presentation/bloc/app_bloc_observer.dart'; -import 'package:clean_architecture/core/presentation/theme/app_theme.dart'; -import 'package:clean_architecture/core/presentation/theme/theme_mode_cubit.dart'; -import 'package:clean_architecture/core/router/app_router.dart'; -import 'package:clean_architecture/features/weather/presentation/bloc/current_weather_bloc.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'injection_container.dart' as ic; - -Future main() async { - WidgetsFlutterBinding.ensureInitialized(); - - // dependency injection setup - ic.setup(); - await ic.getIt.allReady(); - - // register bloc observer - Bloc.observer = AppBlocObserver(); - - runApp(WeatherApp()); -} - -class WeatherApp extends StatelessWidget { - WeatherApp({super.key}); - - final _appRouter = AppRouter(); - - @override - Widget build(BuildContext context) { - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ic.getIt(), +import 'package:clean_architecture/core/presentation/bloc/app_bloc_observer.dart'; +import 'package:clean_architecture/core/presentation/theme/app_theme.dart'; +import 'package:clean_architecture/core/presentation/theme/theme_mode_cubit.dart'; +import 'package:clean_architecture/core/router/app_router.dart'; +import 'package:clean_architecture/features/weather/presentation/bloc/current_weather_bloc.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'injection_container.dart' as ic; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // dependency injection setup + ic.setup(); + await ic.getIt.allReady(); + + // register bloc observer + Bloc.observer = AppBlocObserver(); + + runApp(WeatherApp()); +} + +class WeatherApp extends StatelessWidget { + WeatherApp({super.key}); + + final _appRouter = AppRouter(); + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => ic.getIt(), + ), + BlocProvider( + create: (context) => ic.getIt(), ), - BlocProvider( - create: (context) => ic.getIt(), - ), ], - child: BlocBuilder( - builder: (context, state) { - return MaterialApp.router( - debugShowCheckedModeBanner: false, - title: 'Weather App', - theme: lightTheme, - darkTheme: darkTheme, - themeMode: state, - routerConfig: _appRouter.config(), + child: BlocBuilder( + builder: (context, state) { + return MaterialApp.router( + debugShowCheckedModeBanner: false, + title: 'Weather App', + theme: lightTheme, + darkTheme: darkTheme, + themeMode: state, + routerConfig: _appRouter.config(), ); }, ), - ); + ); } } ``` --- + ## Testing + . --- -All the codes in this set of articles are available on [GitHub](https://github.com/dhemasnurjaya/flutter-clean-architecture), and will be updated regularly because I use them too as my project starter. \ No newline at end of file +All the codes in this set of articles are available on [GitHub](https://github.com/dhemasnurjaya/flutter-clean-architecture), and will be updated regularly because I use them too as my project starter. diff --git a/src/content/blog/kuwot-flutter-daily-quote-app.md b/src/content/blog/kuwot-flutter-daily-quote-app.md index 96f4537..6c6c4a9 100644 --- a/src/content/blog/kuwot-flutter-daily-quote-app.md +++ b/src/content/blog/kuwot-flutter-daily-quote-app.md @@ -1,6 +1,6 @@ --- -title: 'Kuwot' -description: 'Flutter Daily Quote App' +title: "Kuwot" +description: "Flutter Daily Quote App" date: 2025-02-20T15:26:04+07:00 draft: true tags: @@ -11,9 +11,11 @@ tags: I made [Kuwot](https://play.google.com/store/apps/details?id=com.dhemasnurjaya.kuwot) last year in my spare time. I want to share it, you can use it as an inspiration, practice app, or something else is up to you since I open-sourced the code (links below). # Introduction + The Flutter app itself was built using [Clean Architecture]({{< ref "/posts/flutter-clean-architecture/index.md" >}}). It has all basic features from a daily quote application. At first, my idea is to make a simple daily quote application. Showing random quote everytime the app is opened, and give it an image background to make it more appealing. # Hunting for quote data + First thing I did was searching some kind of quote data that available for free. I stumbled across several choices: - Use available quote API, eg: [Zen Quotes](https://zenquotes.io) @@ -25,15 +27,17 @@ After many consideration, I'd like to use this [Quotes 500k](https://github.com/ First problem solved, since I have the quote dataset I was thinking whether I embed this dataset as SQLite database into the app or do something else. But then I remember that I still need to get the background image for the app and it will make no sense to embed the background images into the app as well. So I decided to create a simple REST API for Kuwot. # Building the API + Now I need a simple, easy to make REST API. I was thinking to use Python's [FastAPI](https://fastapi.tiangolo.com/) but after some onboarding tutorial, I just don't like it. Then I found [dart_frog](https://dartfrog.vgv.dev/)! It's a minimalist backend framework written in Dart. There are no reason to not use this since it use same language as my Flutter app, that was I thought. Building the API is straight-forward, [dart_frog](https://dartfrog.vgv.dev/) has everything I need to build the API. # Reshaping dataset + The data I got from [Quotes 500k](https://github.com/ShivaliGoel/Quotes-500K) is looking like this: -| Quote | Author | Tags | -| ----- | ------ | ---- | +| Quote | Author | Tags | +| ---------------------------------------------------------------- | -------------- | ----------------------------------- | | A friend is someone who knows all about you and still loves you. | Elbert Hubbard | friend, friendship, knowledge, love | Now, I didn't want the tags and I don't want a long quote. So I make a Python script to filter those quotes into a new dataset I need. @@ -43,4 +47,5 @@ Now, I didn't want the tags and I don't want a long quote. So I make a Python sc ``` # UI Design -I want to make it as simple as possible while focusing on the functionality. \ No newline at end of file + +I want to make it as simple as possible while focusing on the functionality. diff --git a/src/content/blog/remap-copilot-key-infinix-air-pro-plus.md b/src/content/blog/remap-copilot-key-infinix-air-pro-plus.md index ed721cb..bac7e3f 100644 --- a/src/content/blog/remap-copilot-key-infinix-air-pro-plus.md +++ b/src/content/blog/remap-copilot-key-infinix-air-pro-plus.md @@ -1,6 +1,6 @@ --- -title: 'Remap Infinix Air Pro+ Copilot Key in Linux' -description: 'Re-using Copilot key for something else more useful' +title: "Remap Infinix Air Pro+ Copilot Key in Linux" +description: "Re-using Copilot key for something else more useful" date: 2025-03-01T22:41:32+07:00 draft: false tags: @@ -10,15 +10,17 @@ tags: - tweak --- -> Since I'm moving to Linux, my Copilot key becomes useless. +> Since I'm moving to Linux, my Copilot key becomes useless. Let me rephrase that! Even when I was using Windows, I never using this Copilot shortcut key in my keyboard 😬. Fortunately, using Linux I can remap this key for something else ~~more useful~~. # Requirements + - A laptop (I'm using Infinix Air Pro+) with a working keyboard. - [keyd](https://github.com/rvaiya/keyd) # Finding what this Copilot key do + - Open your favorite terminal and execute `sudo keyd monitor`. This command will print what events are triggered when a particular key is pressed. - Press the Copilot key and read the output. In my laptop, it print out this: @@ -31,13 +33,14 @@ AT Translated Set 2 keyboard 0001:0001:70533846 f23 down - Now I know that my copilot key triggers `leftmeta`, `leftshift`, and `f23`. It's seem legit combination of modifier keys and a function key. But unfortunately when I tried to use it in my desktop environment (I use KDE) to bind a shortcut, it only detect the modifier `meta` and `shift`. # `keyd` for the rescue + Edit `/etc/keyd/default.conf` file and I added these lines: ```plaintext -[ids] -0001:0001:70533846 - -[main] +[ids] +0001:0001:70533846 + +[main] f23 = f13 ``` diff --git a/src/content/blog/welcome.md b/src/content/blog/welcome.md index 38dcc9f..1f80e00 100644 --- a/src/content/blog/welcome.md +++ b/src/content/blog/welcome.md @@ -1,6 +1,6 @@ --- -title: 'Welcome' -description: 'Initial commit!' +title: "Welcome" +description: "Initial commit!" date: 2024-03-18T14:16:19+07:00 draft: false tags: @@ -15,9 +15,8 @@ Long story short, I decide to take an Information Technology major in my local u I never really write anything in my 7 years journey and I feel kinda regretted it. So I decide to create this blog, where I can write anything I found interesting. Maybe it's a coding-related thing, technology heads up, or maybe just my random thought. Feel free read and share! - # Bonus This is a cat from my work, maybe someday she can be a good software tester to find ~~mice~~ bugs from my code. -![Piko 1](/images/piko-1.webp) ![Piko 2](/images/piko-2.webp) ![Piko 3](/images/piko-3.webp) \ No newline at end of file +![Piko 1](/images/piko-1.webp) ![Piko 2](/images/piko-2.webp) ![Piko 3](/images/piko-3.webp) diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index dadcfdb..7eb87be 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -1,7 +1,7 @@ --- -import "../styles/global.css"; -import Footer from "../components/Footer.astro"; -import Header from "../components/Header.astro"; +import "@/styles/global.css"; +import Footer from "@/components/Footer.astro"; +import Header from "@/components/Header.astro"; interface Props { title: string; diff --git a/src/pages/index.astro b/src/pages/index.astro index d1c2ba8..590fa94 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,6 @@ --- -import Welcome from "../components/Welcome.astro"; -import Layout from "../layouts/Layout.astro"; +import Welcome from "@/components/Welcome.astro"; +import Layout from "@/layouts/Layout.astro"; // Welcome to Astro! Wondering what to do next? Check out the Astro documentation at https://docs.astro.build // Don't want to use any of this? Delete everything in this file, the `assets`, `components`, and `layouts` directories, and start fresh. diff --git a/src/pages/posts/[id].astro b/src/pages/posts/[id].astro index c7e4dea..d624698 100644 --- a/src/pages/posts/[id].astro +++ b/src/pages/posts/[id].astro @@ -1,7 +1,7 @@ --- import { getCollection, render, type CollectionEntry, type RenderResult } from "astro:content"; -import Layout from "../../layouts/Layout.astro"; -import BlogPost from "../../components/BlogPost.astro"; +import Layout from "@/layouts/Layout.astro"; +import BlogPost from "@/components/BlogPost.astro"; export async function getStaticPaths() { const posts = await getCollection("blog"); diff --git a/src/pages/posts/index.astro b/src/pages/posts/index.astro index 0b08213..9b4a374 100644 --- a/src/pages/posts/index.astro +++ b/src/pages/posts/index.astro @@ -1,7 +1,7 @@ --- import { getCollection } from "astro:content"; -import Layout from "../../layouts/Layout.astro"; -import BlogPostCard from "../../components/BlogPostCard.astro"; +import Layout from "@/layouts/Layout.astro"; +import BlogPostCard from "@/components/BlogPostCard.astro"; const posts = await getCollection("blog"); posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); diff --git a/src/pages/tags/[name].astro b/src/pages/tags/[name].astro index 7d72788..273f477 100644 --- a/src/pages/tags/[name].astro +++ b/src/pages/tags/[name].astro @@ -1,7 +1,7 @@ --- import { getCollection, type CollectionEntry } from "astro:content"; -import BlogPostCard from "../../components/BlogPostCard.astro"; -import Layout from "../../layouts/Layout.astro"; +import BlogPostCard from "@/components/BlogPostCard.astro"; +import Layout from "@/layouts/Layout.astro"; export async function getStaticPaths() { const posts = await getCollection("blog"); diff --git a/src/pages/tags/index.astro b/src/pages/tags/index.astro index 9d41e27..f58642b 100644 --- a/src/pages/tags/index.astro +++ b/src/pages/tags/index.astro @@ -1,6 +1,6 @@ --- import { getCollection } from "astro:content"; -import Layout from "../../layouts/Layout.astro"; +import Layout from "@/layouts/Layout.astro"; const posts = await getCollection("blog"); const tagsMap = new Map(); diff --git a/src/styles/global.css b/src/styles/global.css index a172626..bf2f8c5 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,15 +1,15 @@ -@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono&display=swap"); @import "tailwindcss"; @plugin '@tailwindcss/typography'; @theme { - --font-sans: 'Noto Sans', sans-serif; - --font-mono: 'JetBrains Mono', monospace; + --font-sans: "Noto Sans", sans-serif; + --font-mono: "JetBrains Mono", monospace; } @layer base { body { @apply bg-zinc-900 text-zinc-300; } -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 8a21e30..da107e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,10 @@ "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"], "compilerOptions": { - "allowJs": true + "allowJs": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } } }