Compare commits
3 commits
96fc5763f9
...
db8b47a7af
| Author | SHA1 | Date | |
|---|---|---|---|
| db8b47a7af | |||
| 39a7b8f5d7 | |||
| 1b061c5e89 |
|
|
@ -1,15 +0,0 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.toml]
|
||||
max_line_length = 100
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
27
.forgejo/workflows/deploy-prod.yml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
echo "${{ secrets.SSH_KEY }}" > ./deploy.key
|
||||
chmod 600 ./deploy.key
|
||||
echo "${{ secrets.SSH_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||
|
||||
- name: Install PM2
|
||||
run: npm i -g pm2
|
||||
|
||||
- name: Deploy
|
||||
run: pm2 deploy ecosystem.config.cjs production
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
name: Deploy Site
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Make and rsync
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y make rsync
|
||||
|
||||
- name: Add Deploy Key to SSH
|
||||
run: |
|
||||
mkdir ~/.ssh
|
||||
echo "${{ secrets.SSH_KEY }}" >> ~/.ssh/id_ed25519_deployer
|
||||
chmod 400 ~/.ssh/id_ed25519_deployer
|
||||
echo -e "Host deployer\n\tUser deployer\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_ed25519_deployer\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
|
||||
- name: Install Hugo
|
||||
run: |
|
||||
wget https://github.com/gohugoio/hugo/releases/download/v0.145.0/hugo_0.145.0_linux-amd64.deb
|
||||
dpkg -i hugo_0.145.0_linux-amd64.deb
|
||||
|
||||
- name: Run Make Deploy
|
||||
run: |
|
||||
make deploy
|
||||
38
.gitignore
vendored
|
|
@ -1,19 +1,25 @@
|
|||
src/articles/*
|
||||
node_modules
|
||||
|
||||
# Generated files by hugo
|
||||
/public/
|
||||
/resources/_gen/
|
||||
/assets/jsconfig.json
|
||||
hugo_stats.json
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Executable may be added to repository
|
||||
hugo.exe
|
||||
hugo.darwin
|
||||
hugo.linux
|
||||
|
||||
# Temporary lock file while building
|
||||
/.hugo_build.lock
|
||||
|
||||
# MacOS
|
||||
static/.DS_Store
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
deploy.key
|
||||
|
|
|
|||
29
Makefile
|
|
@ -1,29 +0,0 @@
|
|||
#!/usr/bin/make -f
|
||||
|
||||
.PHONY: help init build deploy clean serve
|
||||
|
||||
BLOG_REMOTE=deployer:/home/deployer/leomurca.xyz
|
||||
|
||||
help:
|
||||
$(info make init|deploy|build|clean|serve)
|
||||
|
||||
init:
|
||||
echo "Making $@"; \
|
||||
|
||||
build: clean
|
||||
echo "Making $@"
|
||||
hugo --minify
|
||||
|
||||
deploy: build
|
||||
echo "Making $@"
|
||||
rsync -rLtvz public/ $(BLOG_REMOTE)
|
||||
|
||||
clean:
|
||||
echo "Making $@"
|
||||
rm -rf public/
|
||||
|
||||
prod:
|
||||
python3 -m http.server --directory public
|
||||
|
||||
dev:
|
||||
hugo server
|
||||
18
README.md
|
|
@ -2,5 +2,21 @@
|
|||
|
||||

|
||||
|
||||
My personal website source code. You can access it [here](https://leomurca.xyz).
|
||||
Personal site source (SvelteKit). Live site: [leomurca.xyz](https://leomurca.xyz).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Set `PUBLIC_IMAGE_BASE_URL` in `.env` (Cloudinary `…/image/upload` base URL). See `.env.example`.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
| -------------- | ------------------------ |
|
||||
| `npm run dev` | Vite dev server |
|
||||
| `npm run build`| Production build (`build/`) |
|
||||
| `npm run check`| Typecheck + Svelte check |
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
title: "{{ replace .Name "-" " " | title }}"
|
||||
date: {{ .Date }}
|
||||
draft: true
|
||||
---
|
||||
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
---
|
||||
title: "{{ replace .Name "-" " " | title }}"
|
||||
date: {{ .Date }}
|
||||
featured_image: "/img/{{ .Name }}.webp"
|
||||
draft: true
|
||||
---
|
||||
72
config.toml
|
|
@ -1,72 +0,0 @@
|
|||
baseURL = 'https://leomurca.xyz'
|
||||
title = 'Leonardo Murça'
|
||||
defaultContentLanguage='en'
|
||||
|
||||
enableRobotsTXT = true
|
||||
|
||||
# Code Highlight
|
||||
pygmentsstyle = "gruvbox"
|
||||
pygmentscodefences = true
|
||||
pygmentscodefencesguesssyntax = true
|
||||
|
||||
# RSS Feed
|
||||
rssLimit = 20
|
||||
copyright = "copyright"
|
||||
|
||||
[params]
|
||||
description = "home-page-description"
|
||||
featured_image = "/img/avatar.webp"
|
||||
[params.author]
|
||||
name = "Leonardo Murça"
|
||||
email = "leo@leomurca.xyz"
|
||||
|
||||
[languages]
|
||||
[languages.en]
|
||||
languageCode = 'en'
|
||||
contentDir = 'content/en'
|
||||
|
||||
[languages.en.menu]
|
||||
[[languages.en.menu.main]]
|
||||
name = "Home"
|
||||
url = "/"
|
||||
weight = 1
|
||||
|
||||
[[languages.en.menu.main]]
|
||||
name = "All posts"
|
||||
url = "/posts"
|
||||
weight = 2
|
||||
|
||||
[[languages.en.menu.main]]
|
||||
name = "Contact me"
|
||||
url = "/contact"
|
||||
weight = 3
|
||||
|
||||
[[languages.en.menu.main]]
|
||||
name = "Donate"
|
||||
url = "/donate"
|
||||
weight = 4
|
||||
|
||||
[languages.pt-br]
|
||||
languageCode = 'pt-br'
|
||||
contentDir = 'content/pt-br'
|
||||
|
||||
[languages.pt-br.menu]
|
||||
[[languages.pt-br.menu.main]]
|
||||
name = "Página Inicial"
|
||||
url = "/pt-br"
|
||||
weight = 1
|
||||
|
||||
[[languages.pt-br.menu.main]]
|
||||
name = "Publicações"
|
||||
url = "/pt-br/posts"
|
||||
weight = 2
|
||||
|
||||
[[languages.pt-br.menu.main]]
|
||||
name = "Contato"
|
||||
url = "/pt-br/contact"
|
||||
weight = 3
|
||||
|
||||
[[languages.pt-br.menu.main]]
|
||||
name = "Doe"
|
||||
url = "/pt-br/donate"
|
||||
weight = 4
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
title: "Contact Me"
|
||||
date: 2022-10-15
|
||||
description: "If you have any questions, topics to talk about or paid work to offer, feel free to reach me out."
|
||||
featured_image: "/img/avatar.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||
If you have any questions, topics to talk about or paid work to offer, feel free to reach me out.
|
||||
|
||||
- E-mail: leo@leomurca.xyz
|
||||
- LinkedIn: https://linkedin.com/in/leonardoamurca
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
title: "Donate"
|
||||
date: 2022-11-16T19:08:42-03:00
|
||||
description: "If my work or knowledge has helped you in any way, please consider supporting me financially."
|
||||
featured_image: "/img/avatar.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||
If my work or knowledge has helped you in any way, please consider supporting me financially.
|
||||
|
||||
- {{< icon src="bat.svg" alt="Brave Attention Token Icon" >}} **BAT (Brave Attention Token)**: Send me a tip through [Brave Rewards](https://support.brave.com/hc/en-us/articles/360021123971-How-do-I-tip-websites-and-Content-Creators-in-Brave-Rewards-#:~:text=In%20the%20tipping%20banner%20%2C%20the,tip%20to%20complete%20the%20transaction.).
|
||||
|
||||
- {{< icon src="bitcoin.svg" alt="Bitcon Icon" >}} **Bitcoin**: `bc1qpc4lpyr6stxrrg3u0k4clp4crlt6z4j6q845rq`.
|
||||
- {{< icon src="monero.svg" alt="Monero Icon" >}} **Monero**: `8A9iyTskiBh6f6GDUwnUJaYhAW13gNjDYaZYJBftX434D3XLrcGBko4a8kC4pLSfiuJAoSJ7e8rwP8W4StsVypftCp6FGwm`.
|
||||
|
||||
Feel free to also send me a message at **leo@leomurca.xyz** after donating.
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
---
|
||||
title: "Best Place to Store Your Master Password"
|
||||
date: 2023-04-16T12:41:54-03:00
|
||||
featured_image: "/img/best-place-to-store-your-master-password.webp"
|
||||
draft: true
|
||||
---
|
||||
|
||||
# TL;DR
|
||||
|
||||
|
||||
# Motivation
|
||||
|
||||
Personally, I have always been a big fan of [Password Managers](https://en.wikipedia.org/wiki/Password_manager). However, I have had some doubts on how to store it. What place is the most secure? Also, is it **convenient**? With that in mind, I came to a strategy that have been working good for me. Let's check out.
|
||||
|
||||
# The solution
|
||||
|
||||
## pass
|
||||
|
||||
## KeePass
|
||||
|
||||
## Cloud File storage (Nextcloud)
|
||||
|
||||
# The state of the art
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
title: "Best Use Cases for WebViews in Android Development"
|
||||
date: 2023-02-14T19:58:00-03:00
|
||||
featured_image: "/img/best-use-cases-for-webViews-in-android-development.webp"
|
||||
draft: true
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
WebViews in native Android development can be used for a variety of purposes, but some of the best use cases are:
|
||||
|
||||
1. Provide information in your app that you might need to update, such as end-user agreement or a user guide;
|
||||
|
||||
2. Display web pages such as news articles, social media feeds, and web-based applications, within your app.
|
||||
|
||||
3. Authentication: If your app requires authentication with an external service, you can use WebViews to display the login page and handle the authentication flow.
|
||||
|
||||
(INVALID) 4. Payment gateways: You can use WebViews to display payment gateways within your app. This allows you to accept payments from within your app without the need to redirect users to a separate website.
|
||||
|
||||
4. HTML5 games: If your app includes HTML5 games, you can use WebViews to display them within the app.
|
||||
|
||||
5. Advertising: You can use WebViews to display advertisements within your app. This allows you to monetize your app by displaying ads without the need to leave the app.
|
||||
|
||||
6. Custom UI elements: You can use WebViews to create custom UI elements within your app, such as custom buttons or menus.
|
||||
|
||||
7. Chatbots: You can use WebViews to display chatbots within your app. This allows users to interact with chatbots without leaving the app.
|
||||
|
||||
8. Implement a MVP: If you want to validate an idea but you don't have enough android engineers to implementa all natively, just port your existing web app to Android webview.
|
||||
|
||||
Overall, WebViews can be a useful tool for integrating web-based content and services into your native Android app.
|
||||
|
|
@ -1,575 +0,0 @@
|
|||
---
|
||||
title: "Demystifying ViewModel Testing: Strategies for Crafting Test-Friendly ViewModels"
|
||||
date: 2024-03-25T10:54:21-03:00
|
||||
description: "Learn the best practices for testing your ViewModels."
|
||||
featured_image: "/img/demystifying-viewmodel-testing/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
Have you ever been struggling when writing unit tests for your ViewModel? Difficulty
|
||||
when writing tests is a BIG symptom that your ViewModel is poorly written. If the mere
|
||||
thought of testing your ViewModel sends shivers down your spine or if you find yourself
|
||||
wrestling with intricate setups just to verify a simple behavior, fear not – you're not
|
||||
alone. Writing test-friendly ViewModels is a common challenge faced by many
|
||||
developers, but the good news is that it's a challenge that can be conquered.
|
||||
|
||||
In this blog post, we'll explore the reasons behind the testing struggle, unraveling the
|
||||
intricacies of ViewModel design that lead to testing headaches. More importantly, we'll
|
||||
equip you with practical insights and techniques to transform your ViewModels into
|
||||
test-friendly units, making the process of unit testing a seamless and efficient
|
||||
experience. Let's embark on a journey to banish testing woes and elevate your
|
||||
ViewModel game!
|
||||
|
||||
## What's a ViewModel?
|
||||
|
||||
First thing first, we need to define what a ViewModel is and what its existence purpose is.
|
||||
According to the [ViewModel overview](https://developer.android.com/topic/libraries/architecture/viewmodel):
|
||||
*"[...] the ViewModel class is a business logic or screen level state holder.".* In other words,
|
||||
it encapsulates related business logic and it exposes the Screen UI State.
|
||||
|
||||
However, any plain Kotlin class can be used as a StateHolder to encapsulate business
|
||||
logic and expose some Screen UI State. So why do we need ViewModels?
|
||||
|
||||
The main reason that we use a ViewModel instead of a plain Kotlin class is that
|
||||
ViewModels:
|
||||
- Survives configuration changes (lifecycle-aware). These configuration changes
|
||||
are related to a data persistence benefit from using ViewModels.
|
||||
- Has great Jetpack integration and other libraries;
|
||||
- Caches states.
|
||||
|
||||
Well, knowing the definition of a ViewModel and the reasons why we should use it, we
|
||||
must implement and maintain it very carefully. And in case your ViewModel already
|
||||
exists, pay attention to the symptoms that may indicate that it needs some care.
|
||||
|
||||
## Symptoms that indicate your ViewModel needs some care
|
||||
|
||||
|
||||
### 1. Heavy logic
|
||||
Having complex business logic or extensive data manipulation directly in the
|
||||
ViewModel can be a great indicator that your ViewModel needs some attention.
|
||||
This symptom will cause you lots of headaches testing and maintaining it. Notice
|
||||
the `UserProfileViewModel` below, for example:
|
||||
|
||||
```kotlin
|
||||
class UserProfileViewModel(
|
||||
private val userRepository: UserRepository,
|
||||
private val userLocalDataSource: UserLocalDataSource
|
||||
) : ViewModel() {
|
||||
|
||||
private val _userProfileState = MutableStateFlow<UserProfile?>(null)
|
||||
val userProfileState: StateFlow<UserProfile?> get() = _userProfileState
|
||||
|
||||
private val _loadingState = MutableStateFlow<Boolean>(false)
|
||||
val loadingState: StateFlow<Boolean> get() = _loadingState
|
||||
|
||||
init {
|
||||
// Initial loading of user profile
|
||||
loadUserProfile()
|
||||
}
|
||||
|
||||
private fun loadUserProfile() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
_loadingState.emit(true)
|
||||
// Fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
// Processing and transforming user details
|
||||
val processedUserProfile = processUserProfile(remoteUserDetails)
|
||||
// Updating the local database with the processed data
|
||||
userLocalDataSource.updateUserProfile(processedUserProfile)
|
||||
_userProfileState.emit(processedUserProfile)
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
} finally {
|
||||
_loadingState.emit(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processUserProfile(userDetails: UserDetails): UserProfile {
|
||||
// Heavy processing and transformation of user details
|
||||
// ...
|
||||
return UserProfile(/* Processed user profile data */)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Large ViewModels
|
||||
A large class is a well-known [code smell](https://martinfowler.com/bliki/CodeSmell.html)
|
||||
for classes that have too many responsibilities. That's not different for ViewModels. ViewModel's only
|
||||
responsibility is to manage the data for the UI. Having overly large ViewModels
|
||||
with too many responsibilities can make code hard to understand, test, and
|
||||
maintain. Be mindful that following the [SRP](https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html)
|
||||
is fundamental for ViewModels too. See the example below with too many responsibilities for a single ViewModel:
|
||||
|
||||
|
||||
```kotlin
|
||||
class LargeViewModel(
|
||||
private val userRepository: UserRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val analyticsManager: AnalyticsManager,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
// Properties for various data streams
|
||||
private val _userProfileState = MutableStateFlow<UserProfile?>(null)
|
||||
|
||||
val userProfileState: StateFlow<UserProfile?> get() = _userProfileState
|
||||
private val _tasksState = MutableStateFlow<List<Task>>(emptyList())
|
||||
|
||||
val tasksState: StateFlow<List<Task>> get() = _tasksState
|
||||
|
||||
// ... Other properties for different features ...
|
||||
|
||||
private val _loadingState = MutableStateFlow<Boolean>(false)
|
||||
val loadingState: StateFlow<Boolean> get() = _loadingState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
_loadingState.emit(true)
|
||||
// Fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
_userProfileState.emit(processUserProfile(remoteUserDetails))
|
||||
// Fetching and processing tasks
|
||||
val remoteTasks = taskRepository.fetchTasks()
|
||||
_tasksState.emit(processTasks(remoteTasks))
|
||||
// ... Load data for other features ...
|
||||
// Sending analytics events
|
||||
analyticsManager.logEvent("DataLoaded")
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
} finally {
|
||||
_loadingState.emit(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... Other methods for processing data, handling user interactions, etc. ...
|
||||
private suspend fun processUserProfile(userDetails: UserDetails): UserProfile {
|
||||
// Processing user details
|
||||
// ...
|
||||
return UserProfile(/* Processed user profile data */)
|
||||
}
|
||||
|
||||
private suspend fun processTasks(tasks: List<Task>): List<Task> {
|
||||
// Processing tasks
|
||||
// ...
|
||||
return tasks
|
||||
}
|
||||
// ... Other methods for different features ...
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 3. Direct Android Framework References
|
||||
Avoid direct references to Android framework components like Context or View
|
||||
within the ViewModel. This makes the ViewModel less testable and can lead to
|
||||
memory leaks. If something needs a Context in the ViewModel, you should
|
||||
strongly evaluate if that is in the right layer. ViewModels should be designed to
|
||||
be testable in isolation from the Android framework. Don't get your ViewModel
|
||||
too tight to a framework, make it as agnostic as possible. A common example is
|
||||
when we need to access the device Location. See `LocationViewModel` below:
|
||||
|
||||
```kotlin
|
||||
class LocationViewModel(private val context: Context) : ViewModel() {
|
||||
|
||||
private val _locationState = MutableStateFlow<Location?>(null)
|
||||
val locationState: StateFlow<Location?> get() = _locationState
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
init {
|
||||
// Start listening for location updates
|
||||
startLocationUpdates()
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
1000,
|
||||
10,
|
||||
locationListener
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// Handle permission issues
|
||||
_locationState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationListener = object : LocationListener {
|
||||
// Methods implementation …
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Extensive Dependencies
|
||||
A large number of dependencies can increase the coupling between the
|
||||
ViewModel and external components, such as repositories, managers, or
|
||||
services. This can reduce the modularity of the code, making it challenging to
|
||||
isolate and reuse the ViewModel in different contexts or parts of the
|
||||
application. Besides that, it can make your codebase less flexible and
|
||||
adaptable to changes. That's because as the ViewModel relies heavily on
|
||||
specific external components, any changes to those components might
|
||||
necessitate modifications to the ViewModel, creating a ripple effect across the
|
||||
codebase.
|
||||
|
||||
Finally, extensive dependencies often involve complex interactions
|
||||
with external services or repositories, making it difficult to create isolated unit
|
||||
tests for the ViewModel. Testing becomes cumbersome and may require
|
||||
extensive setup, leading to slower and less focused unit tests. The complexity
|
||||
of dependencies may also hinder the creation of mock objects for testing. See
|
||||
the example below:
|
||||
|
||||
```kotlin
|
||||
class ExtensiveDependenciesViewModel(
|
||||
private val userRepository: UserRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val analyticsManager: AnalyticsManager,
|
||||
private val networkManager: NetworkManager,
|
||||
private val locationManager: LocationManager,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
|
||||
private val _resultState = MutableStateFlow<Result>(Result.Loading)
|
||||
val resultState: StateFlow<Result> get() = _resultState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
|
||||
// Fetching and processing tasks
|
||||
val remoteTasks = taskRepository.fetchTasks()
|
||||
// Sending analytics events
|
||||
analyticsManager.logEvent("DataLoaded")
|
||||
// Network connectivity check
|
||||
if (networkManager.isNetworkConnected()) {
|
||||
// Additional logic requiring network connectivity
|
||||
// ...
|
||||
}
|
||||
// Location-related operations
|
||||
val currentLocation = locationManager.getCurrentLocation()
|
||||
// Combine results and update state
|
||||
_resultState.value = combineResults(remoteUserDetails, remoteTasks, currentLocation)
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
_resultState.value = Result.Error(e.message ?: "An error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun combineResults(
|
||||
userDetails: UserDetails,
|
||||
tasks: List<Task>,
|
||||
location: Location?
|
||||
): Result {
|
||||
// Heavy logic for combining user details, tasks, and location
|
||||
// ...
|
||||
return Result.Success(/* Combined result data */)
|
||||
}
|
||||
|
||||
// ... other methods related to extensive dependencies ...
|
||||
companion object {
|
||||
// ... constants or other shared properties ...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Notice that all the symptoms listed above have one thing in common: they make your
|
||||
ViewModel difficult to test. Understanding that all those symptoms share this common
|
||||
trait—hindering the testability of your ViewModel—can be the first step toward crafting a
|
||||
robust and maintainable architecture. Recognizing these signs of a 'diseased'
|
||||
ViewModel equips you with the insight needed to administer targeted solutions,
|
||||
ensuring a streamlined testing process and enhancing the overall resilience of your
|
||||
application.
|
||||
|
||||
Now, let's explore the remedies that will not only alleviate the identified symptoms but
|
||||
also foster a ViewModel that thrives in the realm of effective testing and code quality.
|
||||
|
||||
## Treating a diseased ViewModel
|
||||
|
||||
Let's consider a hypothetical example of a `BadPracticeViewModel` in Kotlin that
|
||||
incorporates several bad practices, including heavy logic, extensive dependencies,
|
||||
direct Android framework reference and a large scope:
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class BadPracticeViewModel @Inject constructor(
|
||||
context: Context,
|
||||
private val userRepository: UserRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val analyticsManager: AnalyticsManager,
|
||||
private val networkManager: NetworkManager,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
|
||||
private val locationManager: LocationManager by lazy {
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
}
|
||||
|
||||
private val _resultState = MutableStateFlow<Result>(Result.Loading)
|
||||
val resultState: StateFlow<Result> get() = _resultState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
startLocationUpdates()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Simulate fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
// Simulate fetching and processing tasks
|
||||
val remoteTasks = taskRepository.fetchTasks()
|
||||
// Simulate sending analytics events
|
||||
analyticsManager.logEvent("DataLoaded")
|
||||
// Simulate network connectivity check
|
||||
if (networkManager.isNetworkConnected()) {
|
||||
// Additional logic requiring network connectivity
|
||||
// ...
|
||||
}
|
||||
|
||||
// Simulate heavy logic for combining user details, tasks, and location
|
||||
val currentLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
val combinedResult = combineResults(remoteUserDetails, remoteTasks, currentLocation)
|
||||
|
||||
// Update state with the combined result
|
||||
_resultState.value = combinedResult
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
_resultState.value = Result.Error(e.message ?: "An error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
MIN_TIME_BETWEEN_UPDATES,
|
||||
MIN_DISTANCE_CHANGE_FOR_UPDATES,
|
||||
locationListener,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// Handle permission issues
|
||||
_resultState.value = Result.Error("Location permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationListener = LocationListener {
|
||||
// Handle location updates
|
||||
}
|
||||
|
||||
private suspend fun combineResults(
|
||||
userDetails: UserDetails,
|
||||
tasks: List<Task>,
|
||||
currentLocation: Location?,
|
||||
): Result {
|
||||
|
||||
// Simulate heavy logic for combining user details and tasks
|
||||
// ...
|
||||
return Result.Success("$userDetails - ${tasks.first()} - $currentLocation")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_TIME_BETWEEN_UPDATES: Long = 1000
|
||||
private const val MIN_DISTANCE_CHANGE_FOR_UPDATES: Float = 10f
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
data object Loading : Result()
|
||||
data class Success(val data: String) : Result()
|
||||
data class Error(val message: String) : Result()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
As you noticed, we have some bad practices incorporated in the code above.
|
||||
Now, let's discuss why this example incorporates bad practices, why it's advisable to
|
||||
avoid such an approach and refactor it following the best practices:
|
||||
|
||||
|
||||
### 1. Heavy Logic
|
||||
**Issue:** The ViewModel is responsible for fetching data, handling network connectivity
|
||||
checks, obtaining location updates, and combining results.
|
||||
|
||||
**Solution:** Apply the Single Responsibility Principle (SRP) separating each responsibility
|
||||
to a different code unit.
|
||||
|
||||
### 2. Large ViewModel
|
||||
**Issue:** The ViewModel handles multiple features and operations, resulting in a larger
|
||||
class with increased complexity.
|
||||
|
||||
**Solution:** Considering that we already separated concerns effectively, consider now
|
||||
breaking down complex UIs into smaller, reusable components or sub-ViewModels. Use
|
||||
ViewModel composition to combine multiple ViewModels into a single, cohesive UI.
|
||||
Each sub-ViewModel can be responsible for managing a specific part of the UI, such as
|
||||
a list item or a form field.
|
||||
|
||||
### 3. Direct References to Android Framework Components
|
||||
**Issue:** The ViewModel directly references the `LocationManager`, tightly coupling it with
|
||||
Android-specific functionality.
|
||||
|
||||
**Solution:** Consider moving them to separate classes outside of the ViewModel. This
|
||||
could be achieved by using a coordinator or presenter pattern, where the ViewModel
|
||||
delegates Android-specific operations to dedicated classes. Also, dependency injection
|
||||
libraries can help with that.
|
||||
|
||||
### 4. Extensive Dependencies
|
||||
**Issue:** The ViewModel depends on multiple external components, including
|
||||
repositories, managers, and Android framework components.
|
||||
|
||||
**Solution:** Introduce [use cases](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) or
|
||||
[interactors](https://proandroiddev.com/why-you-need-use-cases-interactors-142e8a6fe576) to encapsulate complex business logic and
|
||||
data operations.
|
||||
|
||||
|
||||
In practice, it's crucial to design ViewModels with a clear separation of concerns,
|
||||
minimal dependencies, and a focus on managing UI-related concerns. Adopting clean
|
||||
architecture principles and employing appropriate design patterns can lead to more
|
||||
maintainable, testable, and scalable code.
|
||||
|
||||
After applying some refactorings to the ViewModel above, here's the result:
|
||||
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class BadPracticeViewModel @Inject constructor(
|
||||
private val locationManager: LocationManager,
|
||||
private val userDetailsAndTasksUseCase: UserDetailsAndTasksUseCase,
|
||||
private val userDetailsAndTasksResultMapper: UserDetailsAndTasksResultMapper,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
|
||||
private val _resultState = MutableStateFlow<Result>(Result.Loading)
|
||||
val resultState: StateFlow<Result> get() = _resultState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
startLocationUpdates()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val (userDetails, tasks, currentLocation) =
|
||||
userDetailsAndTasksUseCase.fetchUserDetailsAndTasksWithLocation()
|
||||
val combinedResults = userDetailsAndTasksResultMapper.map(userDetails, tasks, currentLocation)
|
||||
// Update state with the combined result
|
||||
_resultState.value = combinedResults
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
_resultState.value = Result.Error(e.message ?: "An error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
MIN_TIME_BETWEEN_UPDATES,
|
||||
MIN_DISTANCE_CHANGE_FOR_UPDATES,
|
||||
locationListener,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// Handle permission issues
|
||||
_resultState.value = Result.Error("Location permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationListener = LocationListener {
|
||||
// Handle location updates
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_TIME_BETWEEN_UPDATES: Long = 1000
|
||||
private const val MIN_DISTANCE_CHANGE_FOR_UPDATES: Float = 10f
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
data object Loading : Result()
|
||||
data class Success(val data: String) : Result()
|
||||
data class Error(val message: String) : Result()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Diving a little deeper into treatment with States
|
||||
You could notice the usage of UI State data structures in the previous examples, like the
|
||||
`sealed class Result`. All these possible Results are immutable and this state is exposed
|
||||
in one place: `resultState`. This approach is an excellent remedy when treating a
|
||||
diseased ViewModel that's hard to test because we would have a finite number of
|
||||
possible view states to validate and a
|
||||
[Single Source of Truth (SSOT)](https://developer.android.com/topic/architecture#single-source-of-truth).
|
||||
|
||||
This SSOT approach promotes consistency and reliability in data management within
|
||||
the application. It helps in maintaining a clear separation of concerns by centralizing
|
||||
data operations in the ViewModel, which acts as a mediator between the UI and the
|
||||
underlying data sources. This architecture facilitates the implementation of
|
||||
[Unidirectional Data Flow (UDF)](https://developer.android.com/topic/architecture?hl=pt-br#unidirectional-data-flow),
|
||||
where data flows in a single direction—from the ViewModel to the UI components—avoiding circular dependencies and ensuring
|
||||
predictable data flow throughout the application lifecycle.
|
||||
|
||||
|
||||

|
||||
|
||||
In the above diagram we can see clearly how the UDF works with ViewModels:
|
||||
|
||||
1. The ViewModel holds and exposes UI State;
|
||||
2. UI notifies ViewModel of events (button click, for example);
|
||||
3. ViewModel handles the events, updates the state, and is consumed by the UI;
|
||||
4. Repeat the flow.
|
||||
|
||||
Independent of the architecture used: MVVM, MVI, MVP, etc, focus on how to make
|
||||
your UI Data predictable, immutable, and unidirectional. By doing that, your application
|
||||
will be easier to understand, test, and maintain, ultimately leading to better user
|
||||
experiences.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Crafting test-friendly ViewModels is a common challenge faced by developers, but it can
|
||||
be overcome with the right approach. By recognizing the symptoms of a poorly
|
||||
designed ViewModel – such as heavy logic, extensive dependencies, direct references
|
||||
to Android framework components, and overly large scope – we can take steps to
|
||||
address these issues and improve the overall quality and maintainability of our
|
||||
codebase.
|
||||
|
||||
Through this journey, we've explored the core principles of ViewModel design and why
|
||||
adhering to best practices is essential. By applying concepts like the Single
|
||||
Responsibility Principle, separating concerns, Single Source of Truth, Unidirectional
|
||||
Data Flow, and employing clean architecture principles, we can refactor our ViewModels
|
||||
to be more modular, testable, and resilient.
|
||||
|
||||
And finally, be patient. Refactor your code little by little with the help of the existing tests.
|
||||
You don't want a healthy ViewModel with wrong behaviors.
|
||||
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
---
|
||||
title: "How to Access Test Only Files From Unit and Instrumented Test Packages"
|
||||
date: 2022-02-19
|
||||
lastmod: 2024-03-24
|
||||
description: "Learn how to make files available from different test packages in Android development."
|
||||
featured_image: "/img/how-to-access-test-only-files-from-unit-and-instrumented-test-packages/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
## TL;DR
|
||||
|
||||
Create a directory called `testCommon` and add the code below to your `build.gradle` file.
|
||||
|
||||
```groovy
|
||||
android {
|
||||
...
|
||||
sourceSets {
|
||||
test { java.srcDirs += src/testCommon }
|
||||
androidTest { java.srcDirs += src/testCommon }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Have you ever been in a situation where you need to access util files from both `test`
|
||||
and `androidTest` packages? If so, this article may be useful for you.
|
||||
|
||||
Let's say that you have a file called `Placeholders.kt` which you use its values
|
||||
as **test doubles** for your unit tests.
|
||||
|
||||
Our test scenario in this case will be the `Success` return of the login method from an
|
||||
use case class called `LoginUseCaseImpl`.
|
||||
|
||||
For example:
|
||||
|
||||
```kotlin
|
||||
// test/Placeholders.kt
|
||||
object Placeholders {
|
||||
const val email = "johndoe@email.com"
|
||||
const val password = "strongpassword123"
|
||||
}
|
||||
|
||||
// test/LoginUseCaseImplTest.kt
|
||||
class LoginUseCaseImplTest {
|
||||
|
||||
private val loginUseCase = LoginUseCaseImpl()
|
||||
|
||||
@Test
|
||||
fun `when call login should return Success`() {
|
||||
// Arrange
|
||||
val email = Placeholders.email
|
||||
val password = Placeholders.password
|
||||
|
||||
// Act
|
||||
val result = loginUseCase.login(email, password)
|
||||
|
||||
// Assert
|
||||
assertEquals(Success, result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now, we need to write **Instrumented Tests** using [Espresso](https://developer.android.com/training/testing/espresso/)
|
||||
to validate the complete login flow. The scenario is: when fill the email and password fields and tap the login button
|
||||
then a success message will display.
|
||||
|
||||
```kotlin
|
||||
// androidTest/LoginScreenInstrumentedTest.kt
|
||||
class LoginScreenInstrumentedTest {
|
||||
@Test
|
||||
fun when_fill_the_email_and_password_fields_and_tap_the_login_button_then_a_success_message_will_display() {
|
||||
// Arrange
|
||||
val email = Placeholders.email
|
||||
val password = Placeholders.password
|
||||
|
||||
// When fill email and password fields
|
||||
onView(withId(R.id.email_field)).perform(ViewActions.typeText(email))
|
||||
onView(withId(R.id.password_field)).perform(ViewActions.typeText(password))
|
||||
|
||||
// And tap the login button
|
||||
onView(withId(R.id.login_button)).perform(ViewActions.click())
|
||||
|
||||
// Then a success message will display.
|
||||
onView(withId(R.id.success_message)).check(matches(isDisplayed()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
By default, the espresso test will be inside the `androidTest` package but our `Placeholders.kt`
|
||||
is available only inside the `test` package, which our `LoginUseCaseImplTest` is also located.
|
||||
So, the Instrumented Test above will not find the `Placeholders.kt`.
|
||||
|
||||
To allow both tests access the same file, we need to create a new package inside `src` which we will place the `Placeholders.kt`.
|
||||
In this example, we'll name it as `testCommon`. After that, we need to tell gradle to consider this new package
|
||||
as a `test` and `androidTest` package. We will put the code below in our `build.gradle` file:
|
||||
|
||||
```groovy
|
||||
android {
|
||||
...
|
||||
sourceSets {
|
||||
test { java.srcDirs += src/testCommon }
|
||||
androidTest { java.srcDirs += src/testCommon }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it! Now both test packages will be able to access our `Placeholders.kt` file!
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
---
|
||||
title: "How to Deploy React Applications Using Github Actions + Rsync"
|
||||
date: 2022-10-15T15:30:22-03:00
|
||||
lastmod: 2024-03-24
|
||||
description: "Learn how to easily deploy a react application"
|
||||
featured_image: "/img/how-to-deploy-react-applications-using-github-actions-+-rsync/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
Source code and deployed app used for this post:
|
||||
- [rsync-deploy-react-app](https://github.com/leomurca/rsync-deploy-react-app);
|
||||
- [tutorials.leomurca.xyz/rsync-deploy-react-app](https://tutorials.leomurca.xyz/rsync-deploy-react-app/);
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Create a user in your server to deploy your application:
|
||||
```shell
|
||||
$ useradd -s /bin/bash -d /home/tutorials -m tutorials
|
||||
$ su tutorials
|
||||
```
|
||||
|
||||
- Create a folder to copy your production files to:
|
||||
```shell
|
||||
$ mkdir rsync-deploy-react-app
|
||||
```
|
||||
|
||||
- Add your private tutorials's user ssh key to your [Action Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets).
|
||||
|
||||
- Create a new github action to deploy your application and paste the code below:
|
||||
```yml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_KEY: ${{secrets.SSH_KEY}}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
- run: mkdir ~/.ssh
|
||||
- run: 'echo "$SSH_KEY" >> ~/.ssh/id_rsa_tutorials'
|
||||
- run: chmod 400 ~/.ssh/id_rsa_tutorials
|
||||
- run: echo -e "Host tutorials\n\tUser tutorials\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_rsa_tutorials\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: rsync -avz --progress build/ tutorials:/home/tutorials/rsync-deploy-react-app --delete
|
||||
```
|
||||
|
||||
Pay attention to the **Pre-requisites**. That's it! Change some code, push it to the main branch and see the magic happening!
|
||||
|
||||
## Motivation
|
||||
|
||||
I've been working on my bachelor's thesis in information systems, which is a simple React application, and I was struggling to find a **simple, secure and fast** way to deploy it to my own [VPS](https://en.wikipedia.org/wiki/Virtual_private_server). However, most of the content that I had found on the internet involves **fancy and complex** solutions like [docker](https://www.docker.com/) and [Kubernetes](https://kubernetes.io/) or **vendor locked** solutions like [Heroku](https://www.heroku.com/) and [Vercel](https://vercel.com/).
|
||||
|
||||
I recognize these tools have their advantages, but have found that for small to medium sized projects they are more effort than they are worth to maintain. All I need to do is build the code and copy the built files to the server. Then, [rsync](https://en.wikipedia.org/wiki/Rsync) came to my knowledge.
|
||||
|
||||
## Pre-requisites
|
||||
|
||||
- An existing React Application;
|
||||
- An server or a hosting service to deploy your application;
|
||||
- Your own domain registered;
|
||||
- [NGINX](https://www.nginx.com/) installed in your server;
|
||||
- Some knowledge about how to register a domain and create a server on some hosting platform (I'll add articles about that in the future).
|
||||
|
||||
## Demo App to Deploy
|
||||
|
||||
I've created an demo app to deploy it to my server. Its source code is available at [rsync-deploy-react-app](https://github.com/leomurca/rsync-deploy-react-app).
|
||||
|
||||

|
||||
|
||||
## Server Setup
|
||||
|
||||
For this tutorial, I'll use my domain `leomurca.xyz` setting up a sub-domain for it. To be more specific, I'll point `tutorial.leomurca.xyz` to my **VPS's** IP: `45.76.5.44`.
|
||||
|
||||
### SSH to your server
|
||||
|
||||
```shell
|
||||
$ ssh root@45.76.5.44
|
||||
```
|
||||
|
||||
### Create a user to manage your application
|
||||
|
||||
To prevent our pipeline to have root access to your server, I'll create a user to manage deployments called `tutorials`:
|
||||
|
||||
```shell
|
||||
$ useradd -s /bin/bash -d /home/tutorials -m tutorials
|
||||
```
|
||||
|
||||
After that, change the user to it:
|
||||
|
||||
```shell
|
||||
$ su tutorials
|
||||
```
|
||||
|
||||
As I created the user named `tutorials`, this user will host for multiple tutorials, so in order to isolate our application, create a specific folder to house our build files:
|
||||
|
||||
```shell
|
||||
$ mkdir rsync-deploy-react-app
|
||||
```
|
||||
|
||||
## Github Action Setup
|
||||
|
||||
Now let's create the `.github/workflows/deploy.yml` to define the pipeline steps. First, add the label for the workflow and when it should be triggered:
|
||||
|
||||
```yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
```
|
||||
|
||||
Above, the workflow will be trigered every time that new code is **pushed** or **merged** to the `main` branch (This happens for Pull Requests merged to the main branch too).
|
||||
|
||||
Then, describe a new job that we will name it as `build-and-deploy` to handle all the steps to build and deploy our app:
|
||||
|
||||
```yml
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_KEY: ${{secrets.SSH_KEY}}
|
||||
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
The `SSH_KEY: ${{secrets.SSH_KEY}}` references [Github Secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) that is allowed to login in our server. It's important to mention that we should add the secret to our repository settings.
|
||||
|
||||
Also, to avoid issues when authenticating to your server using ssh, **use RSA generated keys to authenticate instead of Ed25519 keys**. For more details on that, check this doc on [how to generate a new SSH key](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
|
||||
|
||||
Afterwards, we'll start define the actual steps to be executed:
|
||||
|
||||
```yml
|
||||
...
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
...
|
||||
```
|
||||
|
||||
These first steps will basically define which [NodeJS](https://nodejs.org/en/) version will be used in our pipeline.
|
||||
|
||||
And now, a very important step is to add the commands that will actually be executed in our workflow, pay attention to them:
|
||||
|
||||
```yml
|
||||
...
|
||||
- run: mkdir ~/.ssh
|
||||
- run: 'echo "$SSH_KEY" >> ~/.ssh/id_rsa_tutorials'
|
||||
- run: chmod 400 ~/.ssh/id_rsa_tutorials
|
||||
- run: echo -e "Host tutorials\n\tUser tutorials\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_rsa_tutorials\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
...
|
||||
```
|
||||
|
||||
The configs above we basically:
|
||||
- Copied the `SSH_KEY` to a file;
|
||||
- Created an ssh config to our server using the key created before;
|
||||
- Downloaded the app dependencies and generate the build files to be copied to our server.
|
||||
|
||||
To make the ssh configs more readable, check the code snippet below:
|
||||
```shell
|
||||
Host tutorials
|
||||
User tutorials
|
||||
Hostname 45.76.5.44
|
||||
IdentityFile ~/.ssh/id_rsa_tutorials
|
||||
StrictHostKeyChecking No
|
||||
```
|
||||
|
||||
### Using rsync to deploy to the server
|
||||
|
||||
And finally, add the rsync command to sync the files from the `build/` folder to our server:
|
||||
|
||||
```yml
|
||||
...
|
||||
- run: rsync -avz --progress build/ tutorials:/home/tutorials/rsync-deploy-react-app --delete
|
||||
...
|
||||
```
|
||||
|
||||
The meaning of each flag used are:
|
||||
- `-a`: It is a quick way of saying you want recursion and want to preserve almost everything (with -H being a notable omission);
|
||||
- `-v` (`-verbose`): This option increases the amount of information the daemon logs during its startup phase.
|
||||
- `-z` (`-compress`): compresses the file data as it is sent to the destination machine, which reduces the amount of data being transmitted -- something that is useful over a slow connection.
|
||||
- `--progress`: This option tells rsync to print information showing the progress of the transfer. This gives a bored user something to watch.
|
||||
- `--delete`: This tells rsync to delete extraneous files from the receiving side (ones that aren't on the sending side), but only for the directories that are being synchronized.
|
||||
|
||||
To have more details on all the options for `rsync`, check its [man page](https://linux.die.net/man/1/rsync).
|
||||
|
||||
### Complete `.github/workflows/deploy.yml`
|
||||
|
||||
```yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_KEY: ${{secrets.SSH_KEY}}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
- run: mkdir ~/.ssh
|
||||
- run: 'echo "$SSH_KEY" >> ~/.ssh/id_rsa_tutorials'
|
||||
- run: chmod 400 ~/.ssh/id_rsa_tutorials
|
||||
- run: echo -e "Host tutorials\n\tUser tutorials\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_rsa_tutorials\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: rsync -avz --progress build/ tutorials:/home/tutorials/rsync-deploy-react-app --delete
|
||||
```
|
||||
|
||||
That's it! Change some code, push it to the main branch and see the magic happening!
|
||||
|
||||

|
||||
|
||||
Also, if you want to have more details on the action steps, please check the [actions-executed](https://github.com/leomurca/rsync-deploy-react-app/actions) during this article.
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Simple, fast and secure**, that are the main benefits of using the workflow mentioned in this tutorial. It's really a relief to have these kind of tools in the middle of many bloated solutions.
|
||||
|
||||
If you have any questions or topics to talk about, please [reach me out](/contact)!
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
---
|
||||
title: "How to Push to Multiple Git Remotes With One Command"
|
||||
date: 2022-10-11T16:51:53-03:00
|
||||
lastmod: 2024-03-24
|
||||
description: "Learn how to how to manage multiple remote repositories with git (terminal)."
|
||||
featured_image: "/img/how-to-push-to-multiple-git-remotes-with-one-command/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
Repositories used for the tutorial:
|
||||
|
||||
- [gh-remote](https://github.com/leomurca/gh-remote);
|
||||
- [gl-remote](https://gitlab.com/leomurca/gl-remote.git).
|
||||
|
||||
## TL;DR
|
||||
- Clone the primary repository ([gh-remote](https://github.com/leomurca/gh-remote));
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
- Add the secondary remote ([gl-remote](https://gitlab.com/leomurca/gl-remote.git)) to the cloned folder;
|
||||
|
||||
```shell
|
||||
$ git remote add gitlab git@gitlab.com:leomurca/gl-remote.git
|
||||
```
|
||||
|
||||
- Add a third remote that will be used to push to all the remotes at the same time. For conveniece, we'll name it as `all`. Also, as the url, we'll use the value `fetch-not-supported`;
|
||||
|
||||
```shell
|
||||
$ git remote add all fetch-not-supported
|
||||
```
|
||||
|
||||
- Add the remotes that you want your code to be pushed when `git push all <BRANCH>` is executed;
|
||||
|
||||
```shell
|
||||
$ git remote set-url --add --push all git@gitlab.com:leomurca/gl-remote.git
|
||||
$ git remote set-url --add --push all git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
- And finally to test if it is working, change some code locally, run the command below and check your both remotes (Be aware of the branch that you are using. In our example, we are using the `main` branch).
|
||||
|
||||
```shell
|
||||
$ git push all main
|
||||
Enumerating objects: 5, done.
|
||||
Counting objects: 100% (5/5), done.
|
||||
Writing objects: 100% (3/3), 275 bytes | 91.00 KiB/s, done.
|
||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To gitlab.com:leomurca/gl-remote.git
|
||||
584aa2b..1a17aa1 main -> main
|
||||
Enumerating objects: 8, done.
|
||||
Counting objects: 100% (8/8), done.
|
||||
Delta compression using up to 10 threads
|
||||
Compressing objects: 100% (2/2), done.
|
||||
Writing objects: 100% (6/6), 512 bytes | 85.00 KiB/s, done.
|
||||
Total 6 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To github.com:leomurca/gh-remote.git
|
||||
7e0fb66..1a17aa1 main -> main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
This tutorial is pretty straightforward on how to simplify the synchronization across multiple git remotes, I recommend to save it to your bookmark if you forget any steps.
|
||||
|
||||
## Why to push to multiple git remotes?
|
||||
|
||||
The main use case for pusing to multiple git remotes is to synchronize your changes among all the mirrors used as redundancy for your project. If that is your case (or even if it isn't but you want to learn something new), let's dive in.
|
||||
|
||||
## Adding multiple remotes
|
||||
|
||||
First of all, you need to have the primary git repository cloned to your local machine. You can also start by creating a local repository and then pushing to a remote, but for this tutorial, we'll keep it simple. So, let's clone our primary repository, which in this case, is the [gh-remote](https://github.com/leomurca/gh-remote).
|
||||
|
||||
### Cloning the repo (with the primary remote)
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
After cloning it, check the default remote assigned to it.
|
||||
|
||||
```shell
|
||||
$ git remote -v
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
### Adding a second remote
|
||||
|
||||
You can notice that the default remote name assigned to the primary repo was `origin`, but you can also change it in the future. Right, now let's just add the second remote ([gl-remote](https://gitlab.com/leomurca/gl-remote.git)) to the cloned folder and then list all the remotes afterwards;
|
||||
|
||||
```shell
|
||||
$ git remote add gitlab git@gitlab.com:leomurca/gl-remote.git
|
||||
$ git remote -v
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (fetch)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
### Adding a third remote (push only)
|
||||
We'll add a third remote that will be used to push to all the remotes at the same time. For conveniece, we'll name it as `all`. Also, as the url, we'll use the value `fetch-not-supported`. After that, list the remotes.
|
||||
|
||||
```shell
|
||||
$ git remote add all fetch-not-supported
|
||||
$ git remote -v
|
||||
all fetch-not-supported (fetch)
|
||||
all fetch-not-supported (push)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (fetch)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
And finally, we'll add the remotes that we want our code to be pushed when `git push all <BRANCH>` is executed;
|
||||
|
||||
```shell
|
||||
$ git remote set-url --add --push all git@gitlab.com:leomurca/gl-remote.git
|
||||
$ git remote set-url --add --push all git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
## List all remotes
|
||||
|
||||
Let's see the final result listing all the remotes added before.
|
||||
|
||||
```shell
|
||||
$ git remote -v
|
||||
all fetch-not-supported (fetch)
|
||||
all git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
all git@github.com:leomurca/gh-remote.git (push)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (fetch)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
## Pushing to multiple remotes
|
||||
|
||||
And now that everything is set up, let's test if it is working: change some code locally, run the command below and check your both remotes (Be aware of the branch that you are using. In our example, we are using the `main` branch).
|
||||
|
||||
```shell
|
||||
$ git push all main
|
||||
Enumerating objects: 5, done.
|
||||
Counting objects: 100% (5/5), done.
|
||||
Writing objects: 100% (3/3), 275 bytes | 91.00 KiB/s, done.
|
||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To gitlab.com:leomurca/gl-remote.git
|
||||
584aa2b..1a17aa1 main -> main
|
||||
Enumerating objects: 8, done.
|
||||
Counting objects: 100% (8/8), done.
|
||||
Delta compression using up to 10 threads
|
||||
Compressing objects: 100% (2/2), done.
|
||||
Writing objects: 100% (6/6), 512 bytes | 85.00 KiB/s, done.
|
||||
Total 6 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To github.com:leomurca/gh-remote.git
|
||||
7e0fb66..1a17aa1 main -> main
|
||||
```
|
||||
|
||||
That's all! Enjoy your new git configuration.
|
||||
|
||||
## Extra
|
||||
|
||||
Do want to fetch from multiple remotes? Just execute `git fetch --all`.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
---
|
||||
title: "Embroidery Viewer"
|
||||
description: "A free online tool to view embroidery files built with Svelte."
|
||||
lead_video: "/img/embroidery-viewer/lead-video.mp4"
|
||||
featured_image: "/img/embroidery-viewer/card-screenshot.png"
|
||||
status: "Live"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## About the Project
|
||||
|
||||
**Embroidery Viewer** is a lightweight, privacy-focused, and open-source web application that enables users to preview embroidery files instantly in their browser. It supports a wide range of embroidery file formats, including `.PES`, `.DST`, `.EXP`, `.JEF`, and others.
|
||||
|
||||
The tool runs entirely client-side, meaning files never leave the user’s device, ensuring complete privacy. With a clean and intuitive interface, it allows users to visualize multiple embroidery files at the same time, making it easy to compare and review designs.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- 📂 Supports Multiple Formats: `.DST`, `.PES`, `.JEF`, `.EXP`, `.VP3`, and more
|
||||
- ⚡ Quick Previews: See your embroidery files rendered as images
|
||||
- 🧷 Multiple Files at Once: Upload several designs and view them side-by-side
|
||||
- 🔒 No Upload to Server: Your files stay private – all processing happens locally
|
||||
- ⬇️ Download as Image: Save each embroidery design preview as a PNG
|
||||
- 💸 Fast & Free: No installations, no sign-ups – just open and use
|
||||
|
||||
## 🔧 Tech Stack
|
||||
|
||||
- **Frontend:** Svelte, HTML5, CSS3
|
||||
- **Rendering:** HTML Canvas and SVG
|
||||
- **Hosting:** Self-hosted on a VPS with Nginx
|
||||
- **Other:** Git for version control, lightweight infrastructure
|
||||
|
||||
## 🤝 Open Source & Contributions
|
||||
|
||||
Embroidery Viewer is an **open-source project**. Whether you are a developer, designer, or someone passionate about embroidery, [you are welcome to contribute](https://git.leomurca.xyz/leomurca/embroidery-viewer).
|
||||
|
||||
## 🚀 Challenges & Solutions
|
||||
|
||||
- **Binary Parsing in Browser:** Implemented parsing of complex binary embroidery files client-side.
|
||||
- **Rendering Stitches:** Converted stitch data into SVG or Canvas paths for accurate visualization.
|
||||
- **Performance:** Optimized to handle multiple files with minimal memory footprint and fast parsing.
|
||||
|
||||
## 📈 Impact
|
||||
|
||||
- Helping hobbyists and embroidery professionals preview files without expensive software.
|
||||
- Gained organic traffic from embroidery communities.
|
||||
- Featured in several hobbyist forums and communities.
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- 🌐 [Visit Website](https://embroideryviewer.xyz)
|
||||
- 👨💻 [Source code](https://git.leomurca.xyz/leomurca/embroidery-viewer)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
title: "Entrepreneurship Alagoas"
|
||||
description: "Landing page showcasing Alagoas startup programs with funding and mentorship."
|
||||
lead_video: "/img/entrepreneurship-alagoas/lead-video.mp4"
|
||||
featured_image: "/img/entrepreneurship-alagoas/screenshot.png"
|
||||
status: "Live"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## About the Project
|
||||
|
||||
**Empreendedorismo SECTI Alagoas** is a custom-built landing page created as a freelance project to promote **Lagoon Startup** and **VAI Startup** — two state-backed entrepreneurship programs in Alagoas. These initiatives support local startups and entrepreneurs with funding, mentorship, and structured methodologies to advance their ventures 🚀.
|
||||
|
||||
The site was built using **SvelteKit**, with a focus on a clean, responsive design and optimized performance, presenting program details clearly and effectively to the target audience.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- 🌱 **Program Promotion**: Detailed presentation of **Lagoon Startup** and **VAI Startup**, including funding, timelines, and eligibility
|
||||
- ✅ **Responsive Layout**: Ensures accessibility and readability on mobile, tablet, and desktop
|
||||
- 🌐 **SEO & Performance Optimization**: Uses semantic HTML and fast loading behavior
|
||||
- 🎯 **Clear Call-to-Action**: Highlights registration deadlines, benefits, and next steps
|
||||
|
||||
### 🔧 Tech Stack
|
||||
|
||||
- **Frontend:** SvelteKit, HTML5, CSS3
|
||||
- **Hosting:** Deployed on a VPS
|
||||
- **Dev Tools:** Git for version control; CI/CD pipeline for streamlined deployment
|
||||
|
||||
### 🤝 My Role
|
||||
|
||||
As a freelance full-stack developer, I was responsible for:
|
||||
|
||||
1. Gathering requirements and aligning design with SECTI’s branding
|
||||
2. Designing a responsive and intuitive interface
|
||||
3. Integrating program content (funding details, schedules, instructions)
|
||||
4. Optimizing the landing page for speed, SEO, and accessibility
|
||||
5. Setting up deployment using CI/CD on a VPS
|
||||
|
||||
### 📈 Impact
|
||||
|
||||
- Engaged local entrepreneurs with clear, organized presentation of startup opportunities
|
||||
- Provided a professional digital presence for SECTI’s programs
|
||||
- Contributed to the visibility and credibility of public support for entrepreneurship in Alagoas
|
||||
|
||||
### 🔗 Links
|
||||
|
||||
- 🌐 [Visit website](https://empreendedorismo.secti.al.gov.br/)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
title: "Innovative Technologies for Repair (ITR)"
|
||||
description: "Landing page built with SvelteKit to promote a public call for tech development projects in Brazil, offering training and funding support."
|
||||
lead_video: "/img/itr/lead-video.mp4"
|
||||
featured_image: "/img/itr/screenshot.png"
|
||||
status: "Archived"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## About the Project
|
||||
|
||||
**Innovative Technologies for Repair (ITR)** is a landing page built with **SvelteKit** to promote a public call for technological development projects in Brazil. The site presents key information about the program, including eligibility, benefits, and application details, with a clean design and strong emphasis on clarity and performance.
|
||||
|
||||
The landing page leverages **server-side rendering (SSR)** to improve SEO and loading speed, making it accessible even on slower connections and optimized for discoverability.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- 📄 **Program Overview**: Details about the selection process, training modules, timeline, and benefits
|
||||
- 🎯 **Conversion-Oriented Design**: Clear structure with prominent calls-to-action and application buttons
|
||||
- ⚡ **SSR with SvelteKit**: Server-side rendering for enhanced performance and SEO
|
||||
- ✅ **Responsive Layout**: Clean, adaptive design for all screen sizes
|
||||
- 🌐 **SEO Optimization**: Semantic markup and fast page load
|
||||
|
||||
### 🔧 Tech Stack
|
||||
|
||||
- **Frontend:** SvelteKit (with SSR), HTML5, CSS3
|
||||
- **Hosting:** Self-hosted on a VPS
|
||||
- **Tooling:** Git for version control, CI/CD for automated deployment
|
||||
|
||||
### 🤝 My Role
|
||||
|
||||
As a freelance full-stack developer, I was responsible for:
|
||||
|
||||
1. Designing the responsive layout aligned with the program's identity
|
||||
2. Structuring and developing the site with performance and clarity in mind
|
||||
3. Configuring server-side rendering with SvelteKit
|
||||
4. Optimizing SEO and accessibility
|
||||
5. Automating deployment using CI/CD on a private server
|
||||
|
||||
### 📈 Impact
|
||||
|
||||
- Helped promote the TIR call for proposals to a broader audience
|
||||
- Provided applicants with clear, accessible, and well-organized information
|
||||
- Supported public visibility for funding opportunities and innovation in Brazil
|
||||
|
||||
### 🔗 Links
|
||||
|
||||
- 🌐 [Live demo](https://projects.leomurca.xyz/tir/)
|
||||
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
title: "Lacine"
|
||||
description: "Landing page built with SvelteKit to promote Lacine, a creative lab for filmmakers."
|
||||
lead_video: "/img/lacine/lead-video.mp4"
|
||||
featured_image: "/img/lacine/screenshot.png"
|
||||
status: "Archived"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## About the Project
|
||||
|
||||
**Lacine** is a custom website developed as a freelance project for a client in the independent cinema space. The site was designed to showcase film programming and cultural events with a clean aesthetic and user-focused experience.
|
||||
|
||||
Built from the ground up with responsiveness and performance in mind, it delivers fast-loading, accessible content that works seamlessly across devices.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- 🎬 **Event Listing**: Displays a organic layout with dates and times of each event
|
||||
- ✅ **Responsive Design**: Optimized for mobile, tablet, and desktop
|
||||
- 🌐 **SEO & Performance Optimized**: Semantic structure and fast load times
|
||||
- 💼 **Custom Branding**: Layout tailored to the client’s visual identity
|
||||
- 🎨 **Visual Storytelling**: A cinematic presentation that highlights mood, schedule, and call to action
|
||||
|
||||
## 🔧 Tech Stack
|
||||
|
||||
- **Frontend:** SvelteKit, HTML5, CSS3
|
||||
- **Hosting:** Self-hosted on VPS
|
||||
- **Tooling:** Git for version control, CI/CD for deployment
|
||||
|
||||
## 🤝 My Role
|
||||
|
||||
As a freelance full-stack developer, I was responsible for:
|
||||
|
||||
1. Gathering and refining project requirements with the client
|
||||
2. Designing a responsive and elegant user interface
|
||||
3. Implementing key features (scheduling and event details)
|
||||
4. Optimizing for performance and SEO
|
||||
5. Handling deployment and infrastructure setup
|
||||
6. With the tight schedule, I had to develop everything fast
|
||||
|
||||
## 📈 Impact
|
||||
|
||||
- Helped the client establish a professional online presence with full control over content
|
||||
- The final product stands out for its clarity, aesthetics, and usability in a niche market
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- 🌐 [Live demo](https://projects.leomurca.xyz/lacine/)
|
||||
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
title: "Contato"
|
||||
date: 2022-10-15
|
||||
description: "Caso tenha alguma dúvida, tópicos para falar ou trabalho para oferecer, sinta-se à vontade para entrar em contato comigo."
|
||||
featured_image: "/img/avatar.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||
Caso tenha alguma dúvida, tópicos para falar ou trabalho para oferecer, sinta-se à vontade para entrar em contato comigo.
|
||||
|
||||
- E-mail: leo@leomurca.xyz
|
||||
- LinkedIn: https://linkedin.com/in/leonardoamurca
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
---
|
||||
title: "Doe"
|
||||
date: 2022-11-16T19:08:42-03:00
|
||||
description: "Se meu trabalho ou conhecimento o ajudou de alguma forma, considere me apoiar financeiramente."
|
||||
featured_image: "/img/avatar.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||
Se meu trabalho ou conhecimento te ajudou de alguma forma, considere me apoiar financeiramente.
|
||||
|
||||
- {{< icon src="bat.svg" alt="Ícone do Brave Attention Token" >}} **BAT (Brave Attention Token)**: Me mande uma contribuição através do [Brave Rewards](https://support.brave.com/hc/en-us/articles/360021123971-How-do-I-tip-websites-and-Content-Creators-in-Brave-Rewards-#:~:text=In%20the%20tipping%20banner%20%2C%20the,tip%20to%20complete%20the%20transaction.).
|
||||
|
||||
- {{< icon src="bitcoin.svg" alt="Ícone do Bitcon" >}} **Bitcoin**: `bc1qpc4lpyr6stxrrg3u0k4clp4crlt6z4j6q845rq`.
|
||||
- {{< icon src="monero.svg" alt="Ícone da Monero" >}} **Monero**: `8A9iyTskiBh6f6GDUwnUJaYhAW13gNjDYaZYJBftX434D3XLrcGBko4a8kC4pLSfiuJAoSJ7e8rwP8W4StsVypftCp6FGwm`.
|
||||
|
||||
Sinta-se à vontade para também me enviar uma mensagem para **leo@leomurca.xyz** após a doação.
|
||||
|
|
@ -1,536 +0,0 @@
|
|||
---
|
||||
title: "Desmistificando os testes de ViewModel: estratégias para criar ViewModels fáceis de testar"
|
||||
date: 2024-03-25T10:54:21-03:00
|
||||
description: "Aprenda as melhores práticas para escrever testes para seu ViewModel."
|
||||
featured_image: "/img/demystifying-viewmodel-testing/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
## Introdução
|
||||
|
||||
Você já teve dificuldades ao escrever testes unitários para seu ViewModel? Dificuldades ao
|
||||
escrever testes são um GRANDE sintoma de que seu ViewModel está mal escrito. Se o simples
|
||||
pensamento de testar seu ViewModel te causa arrepios ou se você se vê lutando com setups
|
||||
complicados apenas para verificar um comportamento simples, não tema – você não está sozinho.
|
||||
Escrever ViewModels amigáveis aos testes é um desafio comum enfrentado por muitos desenvolvedores,
|
||||
mas a boa notícia é que é um desafio que pode ser superado.
|
||||
|
||||
Neste post, exploraremos as razões por trás da luta nos testes, desvendando as complexidades
|
||||
do design de ViewModel que levam a dores de cabeça nos testes. Mais importante ainda, iremos equipá-lo
|
||||
com insights e técnicas práticas para transformar seus ViewModels em unidades amigáveis aos testes, tornando
|
||||
o processo de teste unitário uma experiência fluida e eficiente. Vamos embarcar em uma jornada para banir as
|
||||
preocupações com testes e elevar o seu jogo de ViewModel!
|
||||
|
||||
## O que é um ViewModel?
|
||||
|
||||
|
||||
Primeiramente, precisamos definir o que é um ViewModel e qual é o seu propósito de existência. De acordo com a
|
||||
[Visão geral do ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel): *"[…] a classe ViewModel
|
||||
é um detentor de estado da tela ou da lógica de negócios"*. Em outras palavras, ele encapsula a lógica de negócios
|
||||
relacionada e expõe o Estado da Interface do Usuário da Tela.
|
||||
|
||||
No entanto, qualquer classe Kotlin simples pode ser usada como uma detentora de estado (StateHolder) para encapsular a lógica
|
||||
de negócios e expor algum Estado da Interface do Usuário da Tela. Então, por que precisamos de ViewModels?
|
||||
|
||||
A principal razão pela qual usamos um ViewModel em vez de uma classe Kotlin simples é que os ViewModels:
|
||||
|
||||
- Sobrevivem a mudanças de configuração (conscientes do ciclo de vida). Essas mudanças de configuração estão relacionadas
|
||||
a um benefício de persistência de dados ao usar ViewModels.
|
||||
- Possuem ótima integração com o Jetpack e outras bibliotecas;
|
||||
- Fazem cache de estados.
|
||||
|
||||
Conhecendo a definição de um ViewModel e os motivos pelos quais devemos usá-lo, devemos implementá-lo e mantê-lo com muito
|
||||
cuidado. E caso seu ViewModel já exista, preste atenção nos sintomas que podem indicar que ele precisa de alguns cuidados.
|
||||
|
||||
## Sintomas que indicam que seu ViewModel precisa de alguns cuidados
|
||||
|
||||
### 1. Lógica Pesada
|
||||
Ter lógica de negócios complexa ou manipulação extensa de dados diretamente no ViewModel pode ser um ótimo indicador de que
|
||||
seu ViewModel precisa de atenção. Este sintoma causará muitas dores de cabeça ao testá-lo e mantê-lo. Observe o
|
||||
`UserProfileViewModel` abaixo, por exemplo:
|
||||
|
||||
```kotlin
|
||||
class UserProfileViewModel(
|
||||
private val userRepository: UserRepository,
|
||||
private val userLocalDataSource: UserLocalDataSource
|
||||
) : ViewModel() {
|
||||
|
||||
private val _userProfileState = MutableStateFlow<UserProfile?>(null)
|
||||
val userProfileState: StateFlow<UserProfile?> get() = _userProfileState
|
||||
|
||||
private val _loadingState = MutableStateFlow<Boolean>(false)
|
||||
val loadingState: StateFlow<Boolean> get() = _loadingState
|
||||
|
||||
init {
|
||||
// Initial loading of user profile
|
||||
loadUserProfile()
|
||||
}
|
||||
|
||||
private fun loadUserProfile() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
_loadingState.emit(true)
|
||||
// Fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
// Processing and transforming user details
|
||||
val processedUserProfile = processUserProfile(remoteUserDetails)
|
||||
// Updating the local database with the processed data
|
||||
userLocalDataSource.updateUserProfile(processedUserProfile)
|
||||
_userProfileState.emit(processedUserProfile)
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
} finally {
|
||||
_loadingState.emit(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun processUserProfile(userDetails: UserDetails): UserProfile {
|
||||
// Heavy processing and transformation of user details
|
||||
// ...
|
||||
return UserProfile(/* Processed user profile data */)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ViewModels grandes demais
|
||||
Uma classe grande é um [code smell](https://martinfowler.com/bliki/CodeSmell.html) bem conhecido para classes que têm muitas responsabilidades.
|
||||
Isso não é diferente para ViewModels. A única responsabilidade do ViewModel é gerenciar os dados da UI. Ter ViewModels excessivamente grandes
|
||||
com muitas responsabilidades pode dificultar a compreensão, o teste e a manutenção do código. Esteja ciente de que seguir o
|
||||
[SRP](https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html) também é fundamental para ViewModels.
|
||||
Veja o exemplo abaixo com muitas responsabilidades para um único ViewModel:
|
||||
|
||||
```kotlin
|
||||
class LargeViewModel(
|
||||
private val userRepository: UserRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val analyticsManager: AnalyticsManager,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
// Properties for various data streams
|
||||
private val _userProfileState = MutableStateFlow<UserProfile?>(null)
|
||||
|
||||
val userProfileState: StateFlow<UserProfile?> get() = _userProfileState
|
||||
private val _tasksState = MutableStateFlow<List<Task>>(emptyList())
|
||||
|
||||
val tasksState: StateFlow<List<Task>> get() = _tasksState
|
||||
|
||||
// ... Other properties for different features ...
|
||||
|
||||
private val _loadingState = MutableStateFlow<Boolean>(false)
|
||||
val loadingState: StateFlow<Boolean> get() = _loadingState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
_loadingState.emit(true)
|
||||
// Fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
_userProfileState.emit(processUserProfile(remoteUserDetails))
|
||||
// Fetching and processing tasks
|
||||
val remoteTasks = taskRepository.fetchTasks()
|
||||
_tasksState.emit(processTasks(remoteTasks))
|
||||
// ... Load data for other features ...
|
||||
// Sending analytics events
|
||||
analyticsManager.logEvent("DataLoaded")
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
} finally {
|
||||
_loadingState.emit(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... Other methods for processing data, handling user interactions, etc. ...
|
||||
private suspend fun processUserProfile(userDetails: UserDetails): UserProfile {
|
||||
// Processing user details
|
||||
// ...
|
||||
return UserProfile(/* Processed user profile data */)
|
||||
}
|
||||
|
||||
private suspend fun processTasks(tasks: List<Task>): List<Task> {
|
||||
// Processing tasks
|
||||
// ...
|
||||
return tasks
|
||||
}
|
||||
// ... Other methods for different features ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Referências diretas do Framework Android
|
||||
Evite referências diretas a componentes da estrutura Android, como Context ou View, no ViewModel. Isso torna o ViewModel
|
||||
menos testável e pode levar a memory leaks. Se algo precisa de um `context` no ViewModel, você deve avaliar fortemente
|
||||
se ele está na camada correta. ViewModels devem ser projetados para serem testáveis isoladamente da estrutura Android.
|
||||
Não deixe seu ViewModel muito preso a uma estrutura, torne-o o mais agnóstico possível. Um exemplo comum é quando
|
||||
precisamos acessar a localização do dispositivo. Veja `LocationViewModel` abaixo:
|
||||
|
||||
```kotlin
|
||||
class LocationViewModel(private val context: Context) : ViewModel() {
|
||||
|
||||
private val _locationState = MutableStateFlow<Location?>(null)
|
||||
val locationState: StateFlow<Location?> get() = _locationState
|
||||
|
||||
private val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
init {
|
||||
// Start listening for location updates
|
||||
startLocationUpdates()
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
1000,
|
||||
10,
|
||||
locationListener
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// Handle permission issues
|
||||
_locationState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationListener = object : LocationListener {
|
||||
// Methods implementation …
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Dependências Extensas
|
||||
Um grande número de dependências pode aumentar o acoplamento entre o ViewModel e componentes externos, como repositórios,
|
||||
gerenciadores ou serviços. Isso pode reduzir a modularidade do código, tornando difícil isolar e reutilizar o ViewModel
|
||||
em diferentes contextos ou partes do aplicativo. Além disso, pode tornar sua base de código menos flexível e adaptável
|
||||
a mudanças. Isso ocorre porque, como o ViewModel depende muito de componentes externos específicos, qualquer alteração
|
||||
nesses componentes pode exigir modificações no ViewModel, criando um efeito cascata em toda a base de código.
|
||||
|
||||
Por fim, dependências extensas geralmente envolvem interações complexas com serviços ou repositórios externos, dificultando
|
||||
a criação de testes unitários isolados para o ViewModel. Os testes tornam-se complicados e podem exigir configurações extensas,
|
||||
resultando em testes unitários mais lentos e menos focados. A complexidade das dependências também pode dificultar a
|
||||
criação de objetos simulados para teste. Veja o exemplo abaixo:
|
||||
|
||||
```kotlin
|
||||
class ExtensiveDependenciesViewModel(
|
||||
private val userRepository: UserRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val analyticsManager: AnalyticsManager,
|
||||
private val networkManager: NetworkManager,
|
||||
private val locationManager: LocationManager,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
|
||||
private val _resultState = MutableStateFlow<Result>(Result.Loading)
|
||||
val resultState: StateFlow<Result> get() = _resultState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
|
||||
// Fetching and processing tasks
|
||||
val remoteTasks = taskRepository.fetchTasks()
|
||||
// Sending analytics events
|
||||
analyticsManager.logEvent("DataLoaded")
|
||||
// Network connectivity check
|
||||
if (networkManager.isNetworkConnected()) {
|
||||
// Additional logic requiring network connectivity
|
||||
// ...
|
||||
}
|
||||
// Location-related operations
|
||||
val currentLocation = locationManager.getCurrentLocation()
|
||||
// Combine results and update state
|
||||
_resultState.value = combineResults(remoteUserDetails, remoteTasks, currentLocation)
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
_resultState.value = Result.Error(e.message ?: "An error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun combineResults(
|
||||
userDetails: UserDetails,
|
||||
tasks: List<Task>,
|
||||
location: Location?
|
||||
): Result {
|
||||
// Heavy logic for combining user details, tasks, and location
|
||||
// ...
|
||||
return Result.Success(/* Combined result data */)
|
||||
}
|
||||
|
||||
// ... other methods related to extensive dependencies ...
|
||||
companion object {
|
||||
// ... constants or other shared properties ...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
Observe que todos os sintomas listados acima têm uma coisa em comum: eles dificultam o teste do seu ViewModel. Compreender
|
||||
que todos esses sintomas compartilham essa característica comum – dificultando a testabilidade do seu ViewModel – pode ser
|
||||
o primeiro passo para a criação de uma arquitetura robusta e de fácil manutenção. Reconhecer esses sinais de um ViewModel
|
||||
“doente” fornece a você o conhecimento necessário para administrar soluções direcionadas, garantindo um processo de teste
|
||||
simplificado e melhorando a resiliência geral do seu aplicativo.
|
||||
|
||||
Agora, vamos explorar as soluções que não apenas aliviarão os sintomas identificados, mas também promoverão um ViewModel
|
||||
que prospere no domínio dos testes eficazes e da qualidade do código.
|
||||
|
||||
### Tratando um ViewModel doente
|
||||
Vamos considerar um exemplo hipotético: `BadPracticeViewModel` em Kotlin que incorpora várias práticas ruins, incluindo
|
||||
lógica pesada, dependências extensas, referência direta à estrutura Android e um escopo grande:
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class BadPracticeViewModel @Inject constructor(
|
||||
context: Context,
|
||||
private val userRepository: UserRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val analyticsManager: AnalyticsManager,
|
||||
private val networkManager: NetworkManager,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
|
||||
private val locationManager: LocationManager by lazy {
|
||||
context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
}
|
||||
|
||||
private val _resultState = MutableStateFlow<Result>(Result.Loading)
|
||||
val resultState: StateFlow<Result> get() = _resultState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
startLocationUpdates()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
// Simulate fetching user details from a remote server
|
||||
val remoteUserDetails = userRepository.fetchUserDetails()
|
||||
// Simulate fetching and processing tasks
|
||||
val remoteTasks = taskRepository.fetchTasks()
|
||||
// Simulate sending analytics events
|
||||
analyticsManager.logEvent("DataLoaded")
|
||||
// Simulate network connectivity check
|
||||
if (networkManager.isNetworkConnected()) {
|
||||
// Additional logic requiring network connectivity
|
||||
// ...
|
||||
}
|
||||
|
||||
// Simulate heavy logic for combining user details, tasks, and location
|
||||
val currentLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
val combinedResult = combineResults(remoteUserDetails, remoteTasks, currentLocation)
|
||||
|
||||
// Update state with the combined result
|
||||
_resultState.value = combinedResult
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
_resultState.value = Result.Error(e.message ?: "An error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
MIN_TIME_BETWEEN_UPDATES,
|
||||
MIN_DISTANCE_CHANGE_FOR_UPDATES,
|
||||
locationListener,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// Handle permission issues
|
||||
_resultState.value = Result.Error("Location permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationListener = LocationListener {
|
||||
// Handle location updates
|
||||
}
|
||||
|
||||
private suspend fun combineResults(
|
||||
userDetails: UserDetails,
|
||||
tasks: List<Task>,
|
||||
currentLocation: Location?,
|
||||
): Result {
|
||||
|
||||
// Simulate heavy logic for combining user details and tasks
|
||||
// ...
|
||||
return Result.Success("$userDetails - ${tasks.first()} - $currentLocation")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_TIME_BETWEEN_UPDATES: Long = 1000
|
||||
private const val MIN_DISTANCE_CHANGE_FOR_UPDATES: Float = 10f
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
data object Loading : Result()
|
||||
data class Success(val data: String) : Result()
|
||||
data class Error(val message: String) : Result()
|
||||
}
|
||||
}
|
||||
```
|
||||
Como você percebeu, temos algumas práticas inadequadas incorporadas no código acima. Agora, vamos discutir
|
||||
por que este exemplo incorpora más práticas, por que é aconselhável evitar tal abordagem e refatorá-la seguindo
|
||||
as melhores práticas:
|
||||
|
||||
### 1. Lógica pesada
|
||||
**Problema:** O ViewModel é responsável por buscar dados, lidar com a conectividade de rede
|
||||
verificações, obtenção de atualizações de localização e combinação de resultados.
|
||||
|
||||
**Solução:** Aplique o Princípio da Responsabilidade Única (SRP), separando cada
|
||||
responsabilidade em uma unidade de código diferente.
|
||||
|
||||
|
||||
### 2. ViewModel grande demais
|
||||
**Problema:** o ViewModel lida com vários recursos e operações, resultando em uma classe maior com maior complexidade.
|
||||
|
||||
**Solução:** Considerando que já separamos as preocupações de forma eficaz, considere agora dividir UIs complexas
|
||||
em componentes menores e reutilizáveis ou subViewModels. Use a composição do ViewModel para combinar vários
|
||||
ViewModels em uma UI única e coesa. Cada sub-ViewModel pode ser responsável por gerenciar uma parte específica da
|
||||
UI, como um item de lista ou um campo de formulário.
|
||||
|
||||
### 3. Referências diretas do Framework Android
|
||||
**Problema:** o ViewModel faz referência direta ao `LocationManager`, acoplando-o fortemente à funcionalidade específica do Android.
|
||||
|
||||
**Solução:** considere movê-los para classes separadas fora do ViewModel. Isso poderia ser conseguido usando um padrão presenter, onde
|
||||
o ViewModel delega operações específicas do Android para classes dedicadas. Além disso, bibliotecas de injeção de dependência podem ajudar nisso.
|
||||
|
||||
### 4. Dependências Extensas
|
||||
**Problema:** o ViewModel depende de vários componentes externos, incluindo repositórios, gerenciadores e componentes do Framework Android.
|
||||
|
||||
**Solução:** introduza [use cases](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
ou [interactors](https://proandroiddev.com/why-you-need-use-cases-interactors-142e8a6fe576) para encapsular lógica de negócios complexa
|
||||
e operações de dados.
|
||||
|
||||
Na prática, é crucial projetar ViewModels com uma separação clara de preocupações, dependências mínimas e foco no gerenciamento de preocupações
|
||||
relacionadas à UI. Adotar princípios de arquitetura limpa e empregar padrões de design apropriados pode levar a um código mais sustentável,
|
||||
testável e escalonável.
|
||||
|
||||
Depois de aplicar algumas refatorações ao ViewModel acima, aqui está o resultado:
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class BadPracticeViewModel @Inject constructor(
|
||||
private val locationManager: LocationManager,
|
||||
private val userDetailsAndTasksUseCase: UserDetailsAndTasksUseCase,
|
||||
private val userDetailsAndTasksResultMapper: UserDetailsAndTasksResultMapper,
|
||||
// ... other dependencies ...
|
||||
) : ViewModel() {
|
||||
|
||||
private val _resultState = MutableStateFlow<Result>(Result.Loading)
|
||||
val resultState: StateFlow<Result> get() = _resultState
|
||||
|
||||
init {
|
||||
// Initial loading of data for various features
|
||||
loadData()
|
||||
startLocationUpdates()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun loadData() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val (userDetails, tasks, currentLocation) =
|
||||
userDetailsAndTasksUseCase.fetchUserDetailsAndTasksWithLocation()
|
||||
val combinedResults = userDetailsAndTasksResultMapper.map(userDetails, tasks, currentLocation)
|
||||
// Update state with the combined result
|
||||
_resultState.value = combinedResults
|
||||
} catch (e: Exception) {
|
||||
// Handle errors and update UI accordingly
|
||||
_resultState.value = Result.Error(e.message ?: "An error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLocationUpdates() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
MIN_TIME_BETWEEN_UPDATES,
|
||||
MIN_DISTANCE_CHANGE_FOR_UPDATES,
|
||||
locationListener,
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
// Handle permission issues
|
||||
_resultState.value = Result.Error("Location permission denied")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val locationListener = LocationListener {
|
||||
// Handle location updates
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MIN_TIME_BETWEEN_UPDATES: Long = 1000
|
||||
private const val MIN_DISTANCE_CHANGE_FOR_UPDATES: Float = 10f
|
||||
}
|
||||
|
||||
sealed class Result {
|
||||
data object Loading : Result()
|
||||
data class Success(val data: String) : Result()
|
||||
data class Error(val message: String) : Result()
|
||||
}
|
||||
}
|
||||
```
|
||||
## Aprofundando-se um pouco mais no tratamento com os Estados
|
||||
|
||||
Você pode notar o uso de estruturas de dados para gerencia estados da UI nos exemplos anteriores, como o `sealed class Result`.
|
||||
Todos esses resultados possíveis são imutáveis e este estado é exposto em um só lugar: `resultState`. Essa abordagem é um excelente
|
||||
remédio ao tratar um ViewModel doente que é difícil de testar porque teríamos um número finito de estados da View possíveis para
|
||||
validar e uma [Fonte Única de Verdade (SSOT)](https://developer.android.com/topic/architecture?hl=pt-br#single-source-of-truth).
|
||||
|
||||
Esta abordagem SSOT promove consistência e confiabilidade no gerenciamento de dados dentro do aplicativo. Ajuda a manter uma
|
||||
separação clara de preocupações, centralizando as operações de dados no ViewModel, que atua como um mediador entre a UI e as
|
||||
fontes de dados subjacentes. Essa arquitetura facilita a implementação do
|
||||
[Fluxo de Dados Unidirecional (UDF)](https://developer.android.com/topic/architecture?hl=pt-br#unidirectional-data-flow), onde os dados
|
||||
fluem em uma única direção — do ViewModel aos componentes da UI — evitando dependências circulares e garantindo um fluxo de dados
|
||||
previsível durante todo o ciclo de vida do aplicativo.
|
||||
|
||||

|
||||
|
||||
No diagrama acima podemos ver claramente como o UDF funciona com ViewModels:
|
||||
|
||||
1. O ViewModel mantém e expõe o estado da UI;
|
||||
2. A UI notifica o ViewModel sobre eventos (clique do botão, por exemplo);
|
||||
3. ViewModel trata os eventos, atualiza o estado e é consumido pela UI;
|
||||
4. Repita o fluxo.
|
||||
|
||||
Independente da arquitetura utilizada: MVVM, MVI, MVP, etc, concentre-se em como tornar seus dados de UI previsíveis, imutáveis e unidirecionais.
|
||||
Ao fazer isso, seu aplicativo será mais fácil de entender, testar e manter, resultando em melhores experiências do usuário.
|
||||
|
||||
## Conclusão
|
||||
|
||||
Criar ViewModels fáceis de testar é um desafio comum enfrentado pelos desenvolvedores, mas pode ser superado com a abordagem certa. Ao reconhecer
|
||||
os sintomas de um ViewModel mal projetado – como lógica pesada, dependências extensas, referências diretas a componentes do Framework Android e
|
||||
escopo excessivamente grande – podemos tomar medidas para resolver esses problemas e melhorar a qualidade geral e a capacidade de manutenção
|
||||
de nossa base de código.
|
||||
|
||||
Nesta jornada, exploramos os princípios básicos do design do ViewModel e por que aderir às práticas recomendadas é essencial. Ao aplicar conceitos
|
||||
como o Princípio de Responsabilidade Única(SRP), Separação de Preocupações(SOC), Fonte Única de Verdade(SSOT), Fluxo de Dados Unidirecional(UDF)
|
||||
e empregar princípios de arquitetura limpa, podemos refatorar nossos ViewModels para serem mais modulares, testáveis e resilientes.
|
||||
|
||||
E finalmente, seja paciente. Refatore seu código aos poucos com a ajuda dos testes existentes. Você não quer um ViewModel saudável com comportamentos errados.
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
---
|
||||
title: "Como acessar arquivos de teste de diferentes pacotes de teste (Unit e Instrumented) em Android"
|
||||
date: 2022-02-19
|
||||
lastmod: 2024-03-24
|
||||
description: "Aprenda como acessar arquivos de teste de diferentes pacotes de teste (Unit e Instrumented) em Android."
|
||||
featured_image: "/img/how-to-access-test-only-files-from-unit-and-instrumented-test-packages/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
## TL;DR
|
||||
|
||||
Crie um diretório chamado `testCommon` e adicione o trecho de código abaixo ao seu arquivo `build.gradle`.
|
||||
|
||||
```groovy
|
||||
android {
|
||||
...
|
||||
sourceSets {
|
||||
test { java.srcDirs += src/testCommon }
|
||||
androidTest { java.srcDirs += src/testCommon }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Você já esteve em uma situação em que precisa acessar arquivos utilitários tanto do pacote `test`
|
||||
quanto do `androidTest`? Se sim, este artigo pode ser útil para você.
|
||||
|
||||
Digamos que você tenha um arquivo chamado `Placeholders.kt` no qual você usa seus valores
|
||||
como **test doubles** para seus testes de unidade.
|
||||
|
||||
Nosso cenário de teste neste caso será o retorno `Success` do método login de uma
|
||||
classe chamada `LoginUseCaseImpl`.
|
||||
|
||||
Por exemplo:
|
||||
|
||||
```kotlin
|
||||
// test/Placeholders.kt
|
||||
object Placeholders {
|
||||
const val email = "johndoe@email.com"
|
||||
const val password = "strongpassword123"
|
||||
}
|
||||
|
||||
// test/LoginUseCaseImplTest.kt
|
||||
class LoginUseCaseImplTest {
|
||||
|
||||
private val loginUseCase = LoginUseCaseImpl()
|
||||
|
||||
@Test
|
||||
fun `when call login should return Success`() {
|
||||
// Arrange
|
||||
val email = Placeholders.email
|
||||
val password = Placeholders.password
|
||||
|
||||
// Act
|
||||
val result = loginUseCase.login(email, password)
|
||||
|
||||
// Assert
|
||||
assertEquals(Success, result)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agora, precisamos escrever **Testes instrumentados** usando [Espresso](https://developer.android.com/training/testing/espresso/)
|
||||
para validar o fluxo de login completo. O cenário é: ao preencher os campos de e-mail e senha e tocar no botão de login
|
||||
então uma mensagem de sucesso será exibida.
|
||||
|
||||
```kotlin
|
||||
// androidTest/LoginScreenInstrumentedTest.kt
|
||||
class LoginScreenInstrumentedTest {
|
||||
@Test
|
||||
fun when_fill_the_email_and_password_fields_and_tap_the_login_button_then_a_success_message_will_display() {
|
||||
// Arrange
|
||||
val email = Placeholders.email
|
||||
val password = Placeholders.password
|
||||
|
||||
// When fill email and password fields
|
||||
onView(withId(R.id.email_field)).perform(ViewActions.typeText(email))
|
||||
onView(withId(R.id.password_field)).perform(ViewActions.typeText(password))
|
||||
|
||||
// And tap the login button
|
||||
onView(withId(R.id.login_button)).perform(ViewActions.click())
|
||||
|
||||
// Then a success message will display.
|
||||
onView(withId(R.id.success_message)).check(matches(isDisplayed()))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Por padrão, o teste utilizando o Espresso estará dentro do pacote `androidTest`, mas nosso `Placeholders.kt`
|
||||
está disponível apenas dentro do pacote `test`, no qual nosso `LoginUseCaseImplTest` também está localizado.
|
||||
Portanto, o Teste Instrumentado acima não encontrará o `Placeholders.kt`.
|
||||
|
||||
Para permitir que ambos os testes acessem o mesmo arquivo, precisamos criar um novo pacote dentro do `src` que iremos colocar o `Placeholders.kt`.
|
||||
Neste exemplo, vamos nomeá-lo como `testCommon`. Depois disso, precisamos dizer ao gradle para considerar este novo pacote
|
||||
como um pacote `test` e `androidTest`. Colocaremos o código abaixo em nosso arquivo `build.gradle`:
|
||||
|
||||
```groovy
|
||||
android {
|
||||
...
|
||||
sourceSets {
|
||||
test { java.srcDirs += src/testCommon }
|
||||
androidTest { java.srcDirs += src/testCommon }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
É isso! Agora ambos os pacotes de teste poderão acessar nosso arquivo `Placeholders.kt`!
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
---
|
||||
title: "Como fazer o deploy de aplicativos React usando Github Actions + Rsync"
|
||||
date: 2022-10-15T15:30:22-03:00
|
||||
lastmod: 2024-03-24
|
||||
description: "Aprenda como fazer o deploy de aplicativos React usando Github Actions + Rsync"
|
||||
featured_image: "/img/how-to-deploy-react-applications-using-github-actions-+-rsync/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
Código fonte e aplicação em produção utilizado nesse post:
|
||||
- [rsync-deploy-react-app](https://github.com/leomurca/rsync-deploy-react-app);
|
||||
- [tutorials.leomurca.xyz/rsync-deploy-react-app](https://tutorials.leomurca.xyz/rsync-deploy-react-app/);
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Crie um usuário em seu servidor para fazer o deploy de sua aplicação:
|
||||
```shell
|
||||
$ useradd -s /bin/bash -d /home/tutorials -m tutorials
|
||||
$ su tutorials
|
||||
```
|
||||
|
||||
- Crie um diretório para copiar seus arquivos de produção:
|
||||
```shell
|
||||
$ mkdir rsync-deploy-react-app
|
||||
```
|
||||
|
||||
- Add your private tutorials's user ssh key to your [Action Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets).
|
||||
|
||||
- Adicione a chave ssh privada do usuário `tutorials` aos seus [Action Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets).
|
||||
|
||||
- Crie uma nova action no github para fazer o deploy da sua aplicação e cole o código abaixo:
|
||||
```yml
|
||||
# .github/workflows/deploy.yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_KEY: ${{secrets.SSH_KEY}}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
- run: mkdir ~/.ssh
|
||||
- run: 'echo "$SSH_KEY" >> ~/.ssh/id_rsa_tutorials'
|
||||
- run: chmod 400 ~/.ssh/id_rsa_tutorials
|
||||
- run: echo -e "Host tutorials\n\tUser tutorials\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_rsa_tutorials\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: rsync -avz --progress build/ tutorials:/home/tutorials/rsync-deploy-react-app --delete
|
||||
```
|
||||
|
||||
Preste atenção aos **Pré-requisitos**. É isso! Altere algum código, envie-o para o branch principal e veja a mágica acontecendo!
|
||||
|
||||
## Motivação
|
||||
|
||||
Estive trabalhando em minha tese de bacharelado em sistemas de informação, que é um aplicativo React simples, e estava lutando para encontrar uma maneira **simples, segura e rápida** de implantá-lo em meu próprio [VPS](https:/ /en.wikipedia.org/wiki/Virtual_private_server). No entanto, a maior parte do conteúdo que encontrei na internet envolve soluções **fantasiosas e complexas** como [docker](https://www.docker.com/) e [Kubernetes](https://kubernetes.io /) ou soluções **bloqueadas pelo fornecedor** como [Heroku](https://www.heroku.com/) e [Vercel](https://vercel.com/).
|
||||
|
||||
Reconheço que essas ferramentas têm suas vantagens, mas descobri que, para projetos de pequeno e médio porte, elas exigem mais esforço do que vale a pena manter. Tudo o que preciso fazer é criar o código e copiar os arquivos criados para o servidor. Então, [rsync](https://en.wikipedia.org/wiki/Rsync) chegou ao meu conhecimento.
|
||||
|
||||
## Pré-requisitos
|
||||
|
||||
- Uma aplicação React existente;
|
||||
- Um servidor ou um serviço de hospedagem para fazer o deploy de sua aplicação;
|
||||
- Seu próprio domínio registrado;
|
||||
- [NGINX](https://www.nginx.com/) instalado em seu servidor;
|
||||
- Algum conhecimento sobre como registrar um domínio e criar um servidor em alguma plataforma de hospedagem (adicionarei artigos sobre isso no futuro).
|
||||
|
||||
## Aplicativo de demonstração a ser implantado
|
||||
|
||||
Criei um aplicativo de demonstração para fazer o deploy em meu servidor. Seu código-fonte está disponível em [rsync-deploy-react-app](https://github.com/leomurca/rsync-deploy-react-app).
|
||||
|
||||

|
||||
|
||||
## Setup do servidor
|
||||
|
||||
Para este tutorial, usarei meu domínio `leomurca.xyz` configurando um subdomínio para ele. Para ser mais específico, vou apontar `tutorial.leomurca.xyz` para o IP do meu **VPS**: `45.76.5.44`.
|
||||
|
||||
### Logar no servidor via SSH
|
||||
|
||||
```shell
|
||||
$ ssh root@45.76.5.44
|
||||
```
|
||||
|
||||
### Crie um usuário para gerenciar sua aplicação
|
||||
|
||||
Para evitar que nosso pipeline tenha acesso root ao seu servidor, criarei um usuário para gerenciar deploys chamado `tutorials`:
|
||||
|
||||
```shell
|
||||
$ useradd -s /bin/bash -d /home/tutorials -m tutorials
|
||||
```
|
||||
|
||||
Depois disso, logue como o usuário criado:
|
||||
|
||||
```shell
|
||||
$ su tutorials
|
||||
```
|
||||
|
||||
Como criei o usuário chamado `tutorials`, este usuário irá hospedar vários tutoriais, então para isolarmos nossa aplicação, crie uma pasta específica para abrigar nossos arquivos de build:
|
||||
|
||||
```shell
|
||||
$ mkdir rsync-deploy-react-app
|
||||
```
|
||||
|
||||
## Setup da Github Action
|
||||
|
||||
Agora vamos criar o `.github/workflows/deploy.yml` para definir as etapas do pipeline. Primeiro, adicione uma label para o fluxo de trabalho e quando ele deve ser acionado:
|
||||
|
||||
```yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
```
|
||||
|
||||
Acima, o workflow será acionado toda vez que um novo código for **pushed** ou **merged** à branch `main` (isso também acontece para Pull Requests mergeadas à branch principal).
|
||||
|
||||
Em seguida, descreva um novo workflow que vamos nomear como `build-and-deploy` para lidar com todas as etapas para fazer o build e o deploy de nossa aplicação:
|
||||
|
||||
```yml
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_KEY: ${{secrets.SSH_KEY}}
|
||||
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
A `SSH_KEY: ${{secrets.SSH_KEY}}` faz referência a [Github Secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets) que é permitido fazer login em nosso servidor. É importante mencionar que devemos adicionar o secrete às configurações do nosso repositório.
|
||||
|
||||
Além disso, para evitar problemas ao autenticar em seu servidor usando ssh, **use chaves geradas por RSA para autenticar em vez de chaves Ed25519**. Para mais detalhes sobre isso, verifique este documento sobre [como gerar uma nova chave SSH](https://docs.github.com/pt/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent).
|
||||
|
||||
Depois, vamos começar a definir os passos reais a serem executados:
|
||||
|
||||
```yml
|
||||
...
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
...
|
||||
```
|
||||
|
||||
Essas primeiras etapas basicamente definirão qual versão do [NodeJS](https://nodejs.org/en/) será usada em nosso pipeline.
|
||||
|
||||
E agora, um passo muito importante é adicionar os comandos que realmente serão executados em nosso workflow, preste atenção neles:
|
||||
|
||||
```yml
|
||||
...
|
||||
- run: mkdir ~/.ssh
|
||||
- run: 'echo "$SSH_KEY" >> ~/.ssh/id_rsa_tutorials'
|
||||
- run: chmod 400 ~/.ssh/id_rsa_tutorials
|
||||
- run: echo -e "Host tutorials\n\tUser tutorials\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_rsa_tutorials\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
...
|
||||
```
|
||||
|
||||
As configurações acima nós basicamente:
|
||||
- Copiamos a `SSH_KEY` para um arquivo;
|
||||
- Criamos uma configuração ssh para nosso servidor usando a chave criada anteriormente;
|
||||
- Baixamos as dependências do aplicativo e gere os arquivos de compilação para serem copiados para o nosso servidor.
|
||||
|
||||
Para tornar as configurações do ssh mais legíveis, verifique o trecho de código abaixo:
|
||||
```shell
|
||||
Host tutorials
|
||||
User tutorials
|
||||
Hostname 45.76.5.44
|
||||
IdentityFile ~/.ssh/id_rsa_tutorials
|
||||
StrictHostKeyChecking No
|
||||
```
|
||||
|
||||
### Usando rsync para fazer o deploy no servidor
|
||||
|
||||
E, finalmente, adicione o comando rsync para sincronizar os arquivos da pasta `build/` para o nosso servidor:
|
||||
|
||||
```yml
|
||||
...
|
||||
- run: rsync -avz --progress build/ tutorials:/home/tutorials/rsync-deploy-react-app --delete
|
||||
...
|
||||
```
|
||||
|
||||
O significado de cada flag utilizada são:
|
||||
- `-a`: É uma maneira rápida de dizer que você quer recursão e quer preservar quase tudo (com -H sendo uma omissão notável);
|
||||
- `-v` (`-verbose`): Esta opção aumenta a quantidade de informações que o daemon registra durante sua fase de inicialização.
|
||||
- `-z` (`-compress`): comprime os dados do arquivo à medida que são enviados para a máquina de destino, o que reduz a quantidade de dados sendo transmitidos -- algo que é útil em uma conexão lenta.
|
||||
- `--progress`: Esta opção diz ao rsync para imprimir informações mostrando o progresso da transferência. Isso dá a um usuário entediado algo para assistir.
|
||||
- `--delete`: Diz ao rsync para excluir arquivos estranhos do lado receptor (aqueles que não estão no lado remetente), mas apenas para os diretórios que estão sendo sincronizados.
|
||||
|
||||
Para obter mais detalhes sobre todas as opções do `rsync`, consulte sua [man page](https://linux.die.net/man/1/rsync).
|
||||
|
||||
### Arquivo completo `.github/workflows/deploy.yml`
|
||||
|
||||
```yml
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_KEY: ${{secrets.SSH_KEY}}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js 16
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
cache: 'npm'
|
||||
- run: mkdir ~/.ssh
|
||||
- run: 'echo "$SSH_KEY" >> ~/.ssh/id_rsa_tutorials'
|
||||
- run: chmod 400 ~/.ssh/id_rsa_tutorials
|
||||
- run: echo -e "Host tutorials\n\tUser tutorials\n\tHostname 45.76.5.44\n\tIdentityFile ~/.ssh/id_rsa_tutorials\n\tStrictHostKeyChecking No" >> ~/.ssh/config
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: rsync -avz --progress build/ tutorials:/home/tutorials/rsync-deploy-react-app --delete
|
||||
```
|
||||
|
||||
É isso! Altere algum código, envie-o para o branch principal e veja a mágica acontecendo!
|
||||
|
||||

|
||||
|
||||
Além disso, se você quiser obter mais detalhes sobre as etapas da ação, verifique as [ações executadas](https://github.com/leomurca/rsync-deploy-react-app/actions) durante este artigo.
|
||||
|
||||
**Simples, rápido e seguro**, esses são os principais benefícios de usar o fluxo de trabalho mencionado neste tutorial. É realmente um alívio ter esse tipo de ferramenta no meio de tantas soluções inchadas.
|
||||
|
||||
Se você tiver alguma dúvida ou assunto para falar, por favor, [fale comigo](/contato)!
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
---
|
||||
title: "Como fazer push para múltiplos repositórios no git com apenas 1 comando"
|
||||
date: 2022-10-11T16:51:53-03:00
|
||||
lastmod: 2024-03-24
|
||||
description: "Aprenda como fazer push para múltiplos repositórios no git com apenas 1 comando (terminal)."
|
||||
featured_image: "/img/how-to-push-to-multiple-git-remotes-with-one-command/featured_image.webp"
|
||||
draft: false
|
||||
---
|
||||
|
||||

|
||||
|
||||
Repositório utilizados neste tutorial:
|
||||
- [gh-remote](https://github.com/leomurca/gh-remote);
|
||||
- [gl-remote](https://gitlab.com/leomurca/gl-remote.git).
|
||||
|
||||
## TL;DR
|
||||
- Clone repositório primário ([gh-remote](https://github.com/leomurca/gh-remote));
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
- Adicione o remote do repositório secundário ([gl-remote](https://gitlab.com/leomurca/gl-remote.git)) ao diretório clonado;
|
||||
|
||||
```shell
|
||||
$ git remote add gitlab git@gitlab.com:leomurca/gl-remote.git
|
||||
```
|
||||
|
||||
- Adicione um terceiro remote que será utilizado para fazer o push para todos os remotes ao mesmo tempo. Por conveniência, vamos nomeá-lo como `all`. Além disso, nomeie sua url como `fetch-not-supported`;
|
||||
|
||||
```shell
|
||||
$ git remote add all fetch-not-supported
|
||||
```
|
||||
|
||||
- Adicione os remotes que você deseja que seu código seja enviado quando `git push all <BRANCH>` for executado;
|
||||
|
||||
```shell
|
||||
$ git remote set-url --add --push all git@gitlab.com:leomurca/gl-remote.git
|
||||
$ git remote set-url --add --push all git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
- E, finalmente, para testar se está funcionando, altere algum código localmente, execute o comando abaixo e verifique seus dois remotes (esteja ciente da branch que você está usando. Em nosso exemplo, estamos usando o branch `main`).
|
||||
|
||||
```shell
|
||||
$ git push all main
|
||||
Enumerating objects: 5, done.
|
||||
Counting objects: 100% (5/5), done.
|
||||
Writing objects: 100% (3/3), 275 bytes | 91.00 KiB/s, done.
|
||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To gitlab.com:leomurca/gl-remote.git
|
||||
584aa2b..1a17aa1 main -> main
|
||||
Enumerating objects: 8, done.
|
||||
Counting objects: 100% (8/8), done.
|
||||
Delta compression using up to 10 threads
|
||||
Compressing objects: 100% (2/2), done.
|
||||
Writing objects: 100% (6/6), 512 bytes | 85.00 KiB/s, done.
|
||||
Total 6 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To github.com:leomurca/gh-remote.git
|
||||
7e0fb66..1a17aa1 main -> main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Introdução
|
||||
|
||||
Este tutorial é bem direto sobre como simplificar a sincronização entre vários remotes git, recomendo salvá-lo em seus favoritos caso esqueça alguma etapa.
|
||||
|
||||
## Por que fazer o psuh para múltiplos remotes git?
|
||||
|
||||
O principal caso de uso para usar vários remotes git é sincronizar suas alterações entre todos os espelhos usados como redundância para seu projeto. Se esse for o seu caso (ou mesmo se não for, mas você quer aprender algo novo), vamos mergulhar de cabeça.
|
||||
|
||||
## Adicionando múltiplos remotes
|
||||
|
||||
Primeiro de tudo, você precisa ter o repositório git primário clonado em sua máquina local. Você também pode começar criando um repositório local e, em seguida, enviar para um remoto, mas para este tutorial, vamos simplificar. Então, vamos clonar nosso repositório primário, que no caso é o [gh-remote](https://github.com/leomurca/gh-remote).
|
||||
|
||||
### Clonando o repositório (com o remote primário)
|
||||
|
||||
```shell
|
||||
$ git clone git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
Depois de cloná-lo, verifique o remote padrão atribuído a ele.
|
||||
|
||||
```shell
|
||||
$ git remote -v
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
### Adicionando um segundo remote
|
||||
|
||||
You can notice that the default remote name assigned to the primary repo was `origin`, but you can also change it in the future. Right, now let's just add the second remote ([gl-remote](https://gitlab.com/leomurca/gl-remote.git)) to the cloned folder and then list all the remotes afterwards;
|
||||
|
||||
Você pode notar que o nome padrão do remote atribuído ao repositório primário era `origin`, mas também pode alterá-lo no futuro. Certo, agora vamos apenas adicionar o segundo remote ([gl-remote](https://gitlab.com/leomurca/gl-remote.git)) na pasta clonada e depois listar todos os controles remotos;
|
||||
|
||||
```shell
|
||||
$ git remote add gitlab git@gitlab.com:leomurca/gl-remote.git
|
||||
$ git remote -v
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (fetch)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
### Adicionando um terceiro remote (somente push)
|
||||
Adicionaremos um terceiro remote que será usado para enviar para todos os remotes ao mesmo tempo. Por conveniência, vamos nomeá-lo como `all`. Além disso, como url, usaremos o valor `fetch-not-supported`. Depois disso, liste os remotes.
|
||||
|
||||
```shell
|
||||
$ git remote add all fetch-not-supported
|
||||
$ git remote -v
|
||||
all fetch-not-supported (fetch)
|
||||
all fetch-not-supported (push)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (fetch)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
E, finalmente, adicionaremos os remotes que queremos que nosso código seja enviado quando `git push all <BRANCH>` for executado;
|
||||
|
||||
```shell
|
||||
$ git remote set-url --add --push all git@gitlab.com:leomurca/gl-remote.git
|
||||
$ git remote set-url --add --push all git@github.com:leomurca/gh-remote.git
|
||||
```
|
||||
|
||||
## Liste todos os remotes
|
||||
|
||||
Vamos ver o resultado final listando todos os remotes adicionados anteriormente.
|
||||
|
||||
```shell
|
||||
$ git remote -v
|
||||
all fetch-not-supported (fetch)
|
||||
all git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
all git@github.com:leomurca/gh-remote.git (push)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (fetch)
|
||||
gitlab git@gitlab.com:leomurca/gl-remote.git (push)
|
||||
origin git@github.com:leomurca/gh-remote.git (fetch)
|
||||
origin git@github.com:leomurca/gh-remote.git (push)
|
||||
```
|
||||
|
||||
## Fazendo push para múltiplos remotes
|
||||
|
||||
And now that everything is set up, let's test if it is working: change some code locally, run the command below and check your both remotes (Be aware of the branch that you are using. In our example, we are using the `main` branch).
|
||||
|
||||
E agora que tudo está configurado, vamos testar se está funcionando: altere algum código localmente, execute o comando abaixo e verifique seus dois remotes (Fique atento à branch que você está usando. Em nosso exemplo, estamos usando a branch main `main `).
|
||||
|
||||
```shell
|
||||
$ git push all main
|
||||
Enumerating objects: 5, done.
|
||||
Counting objects: 100% (5/5), done.
|
||||
Writing objects: 100% (3/3), 275 bytes | 91.00 KiB/s, done.
|
||||
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To gitlab.com:leomurca/gl-remote.git
|
||||
584aa2b..1a17aa1 main -> main
|
||||
Enumerating objects: 8, done.
|
||||
Counting objects: 100% (8/8), done.
|
||||
Delta compression using up to 10 threads
|
||||
Compressing objects: 100% (2/2), done.
|
||||
Writing objects: 100% (6/6), 512 bytes | 85.00 KiB/s, done.
|
||||
Total 6 (delta 0), reused 0 (delta 0), pack-reused 0
|
||||
To github.com:leomurca/gh-remote.git
|
||||
7e0fb66..1a17aa1 main -> main
|
||||
```
|
||||
|
||||
Isso é tudo! Aproveite sua nova configuração git.
|
||||
|
||||
## Extra
|
||||
|
||||
Quer fazer um fetch de vários remotes? Basta executar `git fetch --all`.
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
title: "Melhores contextos para utilizar WebViews no desenvolvimento Android"
|
||||
date: 2023-02-14T19:58:00-03:00
|
||||
featured_image: "/img/best-use-cases-for-webViews-in-android-development.webp"
|
||||
draft: true
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
WebViews in native Android development can be used for a variety of purposes, but some of the best use cases are:
|
||||
|
||||
1. Provide information in your app that you might need to update, such as end-user agreement or a user guide;
|
||||
|
||||
2. Display web pages such as news articles, social media feeds, and web-based applications, within your app.
|
||||
|
||||
3. Authentication: If your app requires authentication with an external service, you can use WebViews to display the login page and handle the authentication flow.
|
||||
|
||||
(INVALID) 4. Payment gateways: You can use WebViews to display payment gateways within your app. This allows you to accept payments from within your app without the need to redirect users to a separate website.
|
||||
|
||||
4. HTML5 games: If your app includes HTML5 games, you can use WebViews to display them within the app.
|
||||
|
||||
5. Advertising: You can use WebViews to display advertisements within your app. This allows you to monetize your app by displaying ads without the need to leave the app.
|
||||
|
||||
6. Custom UI elements: You can use WebViews to create custom UI elements within your app, such as custom buttons or menus.
|
||||
|
||||
7. Chatbots: You can use WebViews to display chatbots within your app. This allows users to interact with chatbots without leaving the app.
|
||||
|
||||
8. Implement a MVP: If you want to validate an idea but you don't have enough android engineers to implementa all natively, just port your existing web app to Android webview.
|
||||
|
||||
Overall, WebViews can be a useful tool for integrating web-based content and services into your native Android app.
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
---
|
||||
title: "Visualizador de Bordados"
|
||||
description: "Uma ferramenta online gratuita para visualizar arquivos de bordado escrita com Svelte."
|
||||
lead_video: "/img/embroidery-viewer/lead-video.mp4"
|
||||
featured_image: "/img/embroidery-viewer/screenshot.png"
|
||||
status: "Live"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## Sobre o Projeto
|
||||
|
||||
**Embroidery Viewer** é uma aplicação web leve, focada em privacidade e de código aberto que permite visualizar arquivos de bordado instantaneamente no navegador. Ele oferece suporte a uma ampla variedade de formatos de arquivos, incluindo `.PES`, `.DST`, `.EXP`, `.JEF`, entre outros.
|
||||
|
||||
A ferramenta funciona inteiramente no lado do cliente, o que significa que os arquivos nunca saem do dispositivo do usuário, garantindo total privacidade. Com uma interface limpa e intuitiva, permite visualizar múltiplos arquivos de bordado ao mesmo tempo, facilitando a comparação e revisão dos designs.
|
||||
|
||||
### ✨ Funcionalidades
|
||||
|
||||
- 📂 Suporte a Múltiplos Formatos: `.DST`, `.PES`, `.JEF`, `.EXP`, `.VP3` e outros
|
||||
- ⚡ Visualização Instantânea: Veja seus arquivos de bordado renderizados como imagens
|
||||
- 🧷 Múltiplos Arquivos Simultaneamente: Faça upload de vários designs e veja lado a lado
|
||||
- 🔒 Sem Upload para o Servidor: Seus arquivos permanecem privados – todo o processamento ocorre localmente
|
||||
- ⬇️ Baixe como Imagem: Salve cada visualização de design como PNG
|
||||
- 💸 Rápido e Gratuito: Sem instalação, sem cadastro – é só abrir e usar
|
||||
|
||||
## 🔧 Tecnologias Utilizadas
|
||||
|
||||
- **Frontend:** Svelte, HTML5, CSS3
|
||||
- **Renderização:** HTML Canvas e SVG
|
||||
- **Hospedagem:** VPS próprio com Nginx
|
||||
- **Outros:** Git para controle de versão, infraestrutura enxuta
|
||||
|
||||
## 🤝 Código Aberto & Contribuições
|
||||
|
||||
Embroidery Viewer é um projeto de **código aberto**. Seja você desenvolvedor, designer ou entusiasta do bordado, [sua contribuição é bem-vinda](https://git.leomurca.xyz/leomurca/embroidery-viewer).
|
||||
|
||||
## 🚀 Desafios & Soluções
|
||||
|
||||
- **Leitura de Arquivos Binários no Navegador:** Implementado parser de arquivos binários complexos diretamente no lado do cliente.
|
||||
- **Renderização de Pontos:** Conversão dos dados de pontos em caminhos SVG ou Canvas para visualização precisa.
|
||||
- **Desempenho:** Otimizado para lidar com múltiplos arquivos com uso mínimo de memória e processamento rápido.
|
||||
|
||||
## 📈 Impacto
|
||||
|
||||
- Auxilia hobistas e profissionais de bordado a visualizar arquivos sem softwares caros.
|
||||
- Atraiu tráfego orgânico de comunidades de bordado.
|
||||
- Destaque em diversos fóruns e comunidades do nicho.
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- 🌐 [Visite o site](https://embroideryviewer.xyz)
|
||||
- 👨💻 [Código-fonte](https://git.leomurca.xyz/leomurca/embroidery-viewer)
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
---
|
||||
title: "Entrepreneurship Alagoas"
|
||||
description: "Landing page apresentando programas de startups de Alagoas com financiamento e mentoria."
|
||||
lead_video: "/img/entrepreneurship-alagoas/lead-video.mp4"
|
||||
featured_image: "/img/entrepreneurship-alagoas/screenshot.png"
|
||||
status: "Live"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## Sobre o Projeto
|
||||
|
||||
**Empreendedorismo SECTI Alagoas** é uma landing page desenvolvida como projeto freelance para divulgar os programas **Lagoon Startup** e **VAI Startup** — iniciativas públicas que apoiam empreendedores em Alagoas por meio de financiamento, mentoria e metodologia estruturada.
|
||||
|
||||
O site foi desenvolvido com **SvelteKit**, com foco em performance, responsividade e clareza na comunicação das informações.
|
||||
|
||||
### ✨ Funcionalidades
|
||||
|
||||
- 🌱 **Divulgação de Programas**: Apresentação completa dos programas **Lagoon Startup** e **VAI Startup**, com informações sobre benefícios, cronogramas e requisitos
|
||||
- ✅ **Layout Responsivo**: Otimizado para navegação em dispositivos móveis, tablets e desktops
|
||||
- 🌐 **SEO e Performance**: Estrutura semântica e carregamento rápido para melhor desempenho e indexação
|
||||
- 🎯 **Chamadas à Ação**: Destaques para inscrições, objetivos e etapas do processo
|
||||
|
||||
### 🔧 Tecnologias Utilizadas
|
||||
|
||||
- **Frontend:** SvelteKit, HTML5, CSS3
|
||||
- **Hospedagem:** VPS próprio
|
||||
- **Ferramentas:** Git para versionamento; CI/CD para automação de deploy
|
||||
|
||||
### 🤝 Meu Papel
|
||||
|
||||
Como desenvolvedor full-stack freelance, fui responsável por:
|
||||
|
||||
1. Levantamento de requisitos e alinhamento com a identidade da SECTI
|
||||
2. Criação de um layout responsivo e objetivo
|
||||
3. Implementação das seções com conteúdo sobre os programas
|
||||
4. Otimização para SEO, desempenho e acessibilidade
|
||||
5. Configuração do ambiente de deploy automatizado (CI/CD) em VPS
|
||||
|
||||
### 📈 Impacto
|
||||
|
||||
- Auxiliou a SECTI na promoção profissional de seus programas de incentivo ao empreendedorismo
|
||||
- Tornou mais acessíveis as informações para startups e empreendedores locais
|
||||
- Reforçou a presença digital de políticas públicas de fomento à inovação em Alagoas
|
||||
|
||||
### 🔗 Links
|
||||
|
||||
- 🌐 [Acessar página](https://empreendedorismo.secti.al.gov.br/)
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
---
|
||||
title: "Tecnologias Inovadoras para a Reparação (TIR)"
|
||||
description: "Landing page em SvelteKit para divulgar edital de fomento a projetos tecnológicos."
|
||||
lead_video: "/img/itr/lead-video.mp4"
|
||||
featured_image: "/img/itr/screenshot.png"
|
||||
status: "Archived"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## Sobre o Projeto
|
||||
|
||||
**TIR - Tecnologias de Impacto Relevante** é uma landing page desenvolvida com **SvelteKit** para promover um edital voltado ao apoio de projetos de desenvolvimento tecnológico no Brasil. O site apresenta de forma clara as etapas do edital, benefícios oferecidos e critérios de seleção, com foco em conversão e performance.
|
||||
|
||||
A página faz uso de **renderização server-side (SSR)** para melhorar a indexação por motores de busca e otimizar o tempo de carregamento, mesmo em conexões lentas.
|
||||
|
||||
### ✨ Funcionalidades
|
||||
|
||||
- 📄 **Apresentação do Edital**: Informações completas sobre capacitações, critérios, cronograma e benefícios
|
||||
- 🎯 **Foco em Conversão**: Estrutura clara com destaque para botões de inscrição e chamadas à ação
|
||||
- ⚡ **SSR com SvelteKit**: Renderização server-side para melhor SEO e desempenho
|
||||
- ✅ **Design Responsivo**: Visual limpo e compatível com diferentes tamanhos de tela
|
||||
- 🌐 **Otimização SEO**: Marcações semânticas e carregamento rápido
|
||||
|
||||
### 🔧 Tecnologias Utilizadas
|
||||
|
||||
- **Frontend:** SvelteKit (com SSR), HTML5, CSS3
|
||||
- **Hospedagem:** VPS próprio
|
||||
- **Ferramentas:** Git para versionamento; CI/CD para deploy automatizado
|
||||
|
||||
### 🤝 Meu Papel
|
||||
|
||||
Como desenvolvedor full-stack freelance, fui responsável por:
|
||||
|
||||
1. Criar o layout e o design responsivo com base na identidade do programa
|
||||
2. Implementar a estrutura do site com foco em performance e legibilidade
|
||||
3. Configurar a renderização server-side com SvelteKit
|
||||
4. Otimizar o conteúdo para SEO e acessibilidade
|
||||
5. Automatizar o deploy via CI/CD
|
||||
|
||||
### 📈 Impacto
|
||||
|
||||
- Facilitou o acesso às informações do edital TIR para potenciais proponentes
|
||||
- Aumentou a visibilidade do programa junto ao público-alvo por meio de SEO eficiente
|
||||
- Contribuiu para um processo seletivo mais acessível e transparente
|
||||
|
||||
### 🔗 Links
|
||||
|
||||
- 🌐 [Ver demo](https://projects.leomurca.xyz/tir/)
|
||||
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
---
|
||||
title: "Lacine"
|
||||
description: "Landing page desenvolvida com SvelteKit para divulgar o Lacine, um laboratório criativo para cineastas."
|
||||
lead_video: "/img/lacine/lead-video.mp4"
|
||||
featured_image: "/img/lacine/screenshot.png"
|
||||
status: "Archived"
|
||||
draft: false
|
||||
---
|
||||
|
||||
## Sobre o Projeto
|
||||
|
||||
**Lacine** é um site personalizado desenvolvido como um projeto freelance para um cliente do setor de cinema independente. O site foi criado para divulgar uma programação cultural com uma estética limpa e uma experiência centrada no usuário.
|
||||
|
||||
Construído do zero com foco em responsividade e desempenho, ele entrega conteúdo rápido, acessível e que funciona perfeitamente em qualquer dispositivo.
|
||||
|
||||
### ✨ Funcionalidades
|
||||
|
||||
- 🎬 **Listagem de Eventos**: Apresenta uma estrutura orgânica com datas e horários de cada sessão
|
||||
- ✅ **Design Responsivo**: Otimizado para mobile, tablet e desktop
|
||||
- 🌐 **SEO e Performance Otimizados**: Estrutura semântica e carregamento rápido
|
||||
- 💼 **Identidade Visual Personalizada**: Layout adaptado à identidade do cliente
|
||||
- 🎨 **Narrativa Visual**: Apresentação cinematográfica que destaca clima, agenda e chamadas para ação
|
||||
|
||||
## 🔧 Tecnologias Utilizadas
|
||||
|
||||
- **Frontend:** SvelteKit, HTML5, CSS3
|
||||
- **Hospedagem:** VPS próprio
|
||||
- **Ferramentas:** Git para controle de versão, CI/CD para deploy
|
||||
|
||||
## 🤝 Meu Papel
|
||||
|
||||
Como desenvolvedor full-stack freelance, fui responsável por:
|
||||
|
||||
1. Levantar e refinar os requisitos do projeto junto ao cliente
|
||||
2. Criar uma interface elegante e responsiva
|
||||
3. Implementar funcionalidades essenciais (agenda e detalhes dos eventos)
|
||||
4. Otimizar o site para desempenho e SEO
|
||||
5. Configurar a infraestrutura e realizar o deploy
|
||||
6. Com um cronograma apertado, desenvolvi tudo em ritmo acelerado
|
||||
|
||||
## 📈 Impacto
|
||||
|
||||
- Ajudei o cliente a estabelecer uma presença online profissional com controle total do conteúdo
|
||||
- O produto final se destaca pela clareza, estética e usabilidade dentro de um nicho específico
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- 🌐 [Ver demo](https://projects.leomurca.xyz/lacine/)
|
||||
39
ecosystem.config.cjs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'leomurca-prod',
|
||||
script: './build/index.js',
|
||||
time: true,
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
max_restarts: 50,
|
||||
watch: false,
|
||||
max_memory_restart: '1G',
|
||||
env: {
|
||||
PUBLIC_APP_ENV: 'production',
|
||||
NODE_ENV: 'production',
|
||||
PORT: 7296,
|
||||
},
|
||||
},
|
||||
],
|
||||
deploy: {
|
||||
production: {
|
||||
user: 'deployer',
|
||||
host: '45.76.5.44',
|
||||
key: 'deploy.key',
|
||||
ref: 'origin/master',
|
||||
repo: 'git@git.leomurca.xyz:leomurca/leomurca.git',
|
||||
path: '/home/deployer/prod-leomurca',
|
||||
'pre-deploy':
|
||||
'rm -rf node_modules build .svelte-kit && npm ci && PUBLIC_APP_ENV=production npm run build',
|
||||
'post-deploy':
|
||||
'pm2 startOrReload ecosystem.config.cjs --only leomurca-prod --env production && pm2 save',
|
||||
|
||||
env: {
|
||||
PUBLIC_APP_ENV: 'production',
|
||||
NODE_ENV: 'production',
|
||||
PORT: 7296,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
64
i18n/en.toml
|
|
@ -1,64 +0,0 @@
|
|||
[home]
|
||||
other = 'Home'
|
||||
[home-page-description]
|
||||
other="Leonardo Murça Webpage"
|
||||
[my-name-is]
|
||||
other="Hi there! 👋 I'm"
|
||||
[home-introduction]
|
||||
other="I am a passionate software engineer with {{ .years }} years of working experience. My expertise includes advanced skills in Jetpack Compose, Bluetooth Low Energy (BLE), Android Streaming Engineering, UI testing, and Coroutines, enabling me to deliver high-performance Android solutions. I have a proven track record of successfully mentoring and training new interns, contributing to team growth and skill development. I also have some good knowledge in Web Development. :)"
|
||||
[home-position]
|
||||
other="Android Engineer at"
|
||||
[my-projects]
|
||||
other="Projects"
|
||||
[my-projects-description]
|
||||
other="Freelance and side projects."
|
||||
[posted-on]
|
||||
other="Posted on "
|
||||
[last-update]
|
||||
other="Last update "
|
||||
[posts]
|
||||
other="Posts"
|
||||
[post-date-format]
|
||||
other="Jan 2, 2006"
|
||||
[single-post-date-format]
|
||||
other="Jan 2, 2006"
|
||||
[embroidery-viewer-title]
|
||||
other="Embroidery Viewer"
|
||||
[embroidery-viewer-description]
|
||||
other="A free online tool to view embroidery files built with Svelte."
|
||||
[lacine-title]
|
||||
other="Lacine"
|
||||
[lacine-description]
|
||||
other="Landing page built with SvelteKit to promote Lacine, a creative lab for filmmakers."
|
||||
[entrepreneurship-alagoas-title]
|
||||
other="Entrepreneurship Alagoas"
|
||||
[entrepreneurship-alagoas-description]
|
||||
other="Landing page built with SvelteKit to promote Lagoon Startup and VAI Startup, two programs supporting entrepreneurs in Alagoas with funding, mentorship, and methodology."
|
||||
[itr-title]
|
||||
other="Innovative Technologies for Repair (ITR)"
|
||||
[itr-description]
|
||||
other="Landing page built with SvelteKit to promote a public call for tech development projects in Brazil, offering training and funding support."
|
||||
[iebtplus-title]
|
||||
other="IEBT+"
|
||||
[iebtplus-description]
|
||||
other="Platform built with SvelteKit to connect startups with exclusive partner benefits through IEBT Plus. I also developed an admin application with Strapi to manage partner data."
|
||||
[vumbora-startups-title]
|
||||
other="Vum Bora Startups"
|
||||
[vumbora-startups-description]
|
||||
other="Landing page and a CMS for a project that capacitates, axcelerates and reward business with growth potential."
|
||||
[copyright]
|
||||
other="Leonardo Murça All rights reserved"
|
||||
[rss-description-recent-content]
|
||||
other="Recent content"
|
||||
[rss-in]
|
||||
other="in"
|
||||
[rss-on]
|
||||
other="on"
|
||||
[all-rights-reserved]
|
||||
other="All rights reserved"
|
||||
[projectStatus_live]
|
||||
other = "Live"
|
||||
[projectStatus_archived]
|
||||
other = "Archived"
|
||||
[my-resume]
|
||||
other = "My resume"
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
[home]
|
||||
other = 'Página Inicial'
|
||||
[home-page-description]
|
||||
other="Webpage de Leonardo Murça"
|
||||
[my-name-is]
|
||||
other="Olá, 👋 sou "
|
||||
[home-introduction]
|
||||
other="Sou um engenheiro de software Android apaixonado, com {{ .years }} anos de experiência na construção de aplicações seguras e confiáveis. Minha expertise inclui habilidades avançadas em Jetpack Compose, Bluetooth Low Energy (BLE), Android Media Streaming, testes de UI e Coroutines, permitindo-me entregar soluções Android de alto desempenho. Tenho um histórico comprovado de sucesso em mentorar e treinar novos estagiários, contribuindo para o crescimento da equipe e desenvolvimento de habilidades. Também tenho bons conhecimentos em Desenvolvimento Web. :)"
|
||||
[home-position]
|
||||
other="Desenvolvedor Android na"
|
||||
[my-projects]
|
||||
other="Projetos"
|
||||
[my-projects-description]
|
||||
other="Freelance e projetos paralelos."
|
||||
[posted-on]
|
||||
other="Publicado em "
|
||||
[last-update]
|
||||
other="Última atualização "
|
||||
[posts]
|
||||
other="Publicações"
|
||||
[post-date-format]
|
||||
other="2 Jan 2006"
|
||||
[single-post-date-format]
|
||||
other="2 de January, 2006"
|
||||
[embroidery-viewer-title]
|
||||
other="Visualizador de bordados"
|
||||
[embroidery-viewer-description]
|
||||
other="Uma ferramenta online gratuita para visualizar arquivos de bordado escrita com Svelte."
|
||||
[lacine-title]
|
||||
other="Lacine"
|
||||
[lacine-description]
|
||||
other="Landing page desenvolvida com SvelteKit para divulgar o Lacine, um laboratório criativo para cineastas."
|
||||
[entrepreneurship-alagoas-title]
|
||||
other="Empreendedorismo Alagoas"
|
||||
[entrepreneurship-alagoas-description]
|
||||
other="Landing page desenvolvida com SvelteKit para divulgar o Lagoon Startup e o VAI Startup, dois programas que apoiam empreendedores em Alagoas com subvenção, mentoria e metodologia."
|
||||
[itr-title]
|
||||
other="Tecnologias Inovadoras para a Reparação (TIR)"
|
||||
[itr-description]
|
||||
other="Landing page desenvolvida com SvelteKit para divulgar um edital de projetos de desenvolvimento tecnológico, com capacitação e apoio financeiro."
|
||||
[iebtplus-title]
|
||||
other="IEBT+"
|
||||
[iebtplus-description]
|
||||
other="Plataforma desenvolvida com SvelteKit para conectar startups aos benefícios exclusivos do IEBT Plus. Também criei uma aplicação administrativa com Strapi para gerenciar os parceiros."
|
||||
[vumbora-startups-title]
|
||||
other="Vum Bora Startups"
|
||||
[vumbora-startups-description]
|
||||
other="Landing page e um CMS para um projeto que capacita, acelera e recompensa negócios com potencial de crescimento."
|
||||
[copyright]
|
||||
other="Leonardo Murça todos os direitos reservados."
|
||||
[rss-description-recent-content]
|
||||
other="Últimos"
|
||||
[rss-in]
|
||||
other=" "
|
||||
[rss-on]
|
||||
other="de"
|
||||
[all-rights-reserved]
|
||||
other="Todos os direitos reservados"
|
||||
[projectStatus_live]
|
||||
other = "No ar"
|
||||
[projectStatus_archived]
|
||||
other = "Arquivado"
|
||||
[my-resume]
|
||||
other = "Meu currículo"
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.LanguageCode }}">
|
||||
{{- partial "head.html" . -}}
|
||||
|
||||
<body>
|
||||
{{- partial "header.html" . -}}
|
||||
{{- block "main" . }}{{- end }}
|
||||
{{- partial "footer.html" . -}}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{{ define "main"}}
|
||||
<main id="main">
|
||||
<section>
|
||||
<h1>{{i18n "posts" }}</h1>
|
||||
<div class="articles-list">
|
||||
<ul>
|
||||
{{ range .Pages }}
|
||||
<li>
|
||||
<time datetime="{{ .Date }}">{{ dateFormat (i18n "post-date-format") .Date }}</time>
|
||||
<a href="{{ .RelPermalink }}">{{ .Title }} </a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{ end }}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
User-agent: *
|
||||
Sitemap: https://www.leomurca.xyz/sitemap.xml
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
{{- $pctx := . -}}
|
||||
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
|
||||
{{- $pages := slice -}}
|
||||
{{- if or $.IsHome $.IsSection -}}
|
||||
{{- $pages = $pctx.RegularPages -}}
|
||||
{{- else -}}
|
||||
{{- $pages = $pctx.Pages -}}
|
||||
{{- end -}}
|
||||
{{- $limit := .Site.Config.Services.RSS.Limit -}}
|
||||
{{- if ge $limit 1 -}}
|
||||
{{- $pages = $pages | first $limit -}}
|
||||
{{- end -}}
|
||||
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>{{ if eq .Title .Site.Title }}{{ .Site.Title }}{{ else }}{{ with .Title }}{{.}} {{ i18n "rss-on"}} {{ end }}{{ .Site.Title }}{{ end }}</title>
|
||||
<link>{{ .Permalink }}</link>
|
||||
<description>{{ i18n "rss-description-recent-content" }} {{ if ne .Title .Site.Title }}{{ with .Title }} {{ i18n "rss-in"}} {{.}} {{ end }}{{ end }}{{ i18n "rss-on"}} {{ .Site.Title }}</description>
|
||||
{{ with .Site.LanguageCode }}
|
||||
<language>{{.}}</language>{{end}}{{ with .Site.Params.author.email }}
|
||||
{{end}}{{ with .Site.Params.author.email }}
|
||||
{{end}}{{ with (i18n .Site.Copyright) }}
|
||||
<copyright>{{ . }}</copyright>{{end}}{{ if not .Date.IsZero }}
|
||||
<lastBuildDate>{{ i18n .Date.Day }}{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</lastBuildDate>{{ end }}
|
||||
{{- with .OutputFormats.Get "RSS" -}}
|
||||
{{ printf "<atom:link href=%q rel=\"self\" type=%q />" .Permalink .MediaType | safeHTML }}
|
||||
{{- end -}}
|
||||
{{ range $pages }}
|
||||
<item>
|
||||
<title>{{ .Title }}</title>
|
||||
<link>{{ .Permalink }}</link>
|
||||
<pubDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" | safeHTML }}</pubDate>
|
||||
{{ with .Site.Params.author.email }}<author>{{.}}{{ with $.Site.Params.author.name }} ({{.}}){{end}}</author>{{end}}
|
||||
<guid>{{ .Permalink }}</guid>
|
||||
<description>{{ .Content | html }}</description>
|
||||
</item>
|
||||
{{ end }}
|
||||
</channel>
|
||||
</rss>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{{ define "main" }}
|
||||
<main id="main">
|
||||
<article>
|
||||
<h1 class="title">{{ .Title }}</h1>
|
||||
<section class="body">
|
||||
{{ .Content }}
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
{{ end }}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ .Site.LanguageCode }}">
|
||||
{{ partial "head.html" . }}
|
||||
|
||||
<body>
|
||||
{{ partial "header.html" . }}
|
||||
<main id="main">
|
||||
<section class="about-me-home">
|
||||
<div class="about-me-home-left">
|
||||
<span>{{ i18n "my-name-is"}}</span>
|
||||
<h1 class="about-me-home-name">Leonardo.</h1>
|
||||
<h2 class="about-me-home-current-position">
|
||||
{{ i18n "home-position"}}
|
||||
<a href="https://arctouch.com/" target="_blank">ArcTouch</a>
|
||||
</h2>
|
||||
<p>
|
||||
{{ i18n "home-introduction" (dict "years" (sub (now.Year) 2018)) }}
|
||||
</p>
|
||||
<div id="about-me-home-icons">
|
||||
<a
|
||||
class="my-resume"
|
||||
href="https://git.leomurca.xyz/leomurca/resume/releases/download/latest/leonardo-murca-resume.pdf"
|
||||
target="_blank"
|
||||
aria-label="{{ i18n "my-resume" }}"
|
||||
>
|
||||
<div>{{ i18n "my-resume" }}</div>
|
||||
</a>
|
||||
<div class="icons-divider"></div>
|
||||
<a
|
||||
href="https://git.leomurca.xyz/"
|
||||
target="_blank"
|
||||
aria-label="My personal git server."
|
||||
>
|
||||
<div class="main-icons">{{ partial "svg" "git" }}</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/in/leonardoamurca/"
|
||||
target="_blank"
|
||||
aria-label="My personal LinkedIn profile."
|
||||
>
|
||||
<div class="main-icons">{{ partial "svg" "linkedin" }}</div>
|
||||
</a>
|
||||
<a
|
||||
href="{{ .Site.Home.RelPermalink }}posts/index.xml"
|
||||
target="_blank"
|
||||
aria-label="My RSS Feed."
|
||||
>
|
||||
<div class="main-icons">{{ partial "svg" "rss" }}</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
width="250"
|
||||
height="250"
|
||||
src="/img/avatar.webp"
|
||||
srcset="/img/avatar-mobile.webp 600w, /img/avatar.webp 1080w"
|
||||
sizes="50vw"
|
||||
class="avatar"
|
||||
alt="A picture of me smiling, wearing a blue t-shirt and a stone collar with some coconut trees behind me."
|
||||
/>
|
||||
</section>
|
||||
<section id="my-projects-section">
|
||||
<h2>{{ i18n "my-projects"}}</h2>
|
||||
<p>{{i18n "my-projects-description" }}</p>
|
||||
<div id="card-list">
|
||||
<article class="card">
|
||||
<img
|
||||
src="/img/embroidery-viewer/card-screenshot.webp"
|
||||
width="450"
|
||||
alt="Embroidery viewer screenshot."
|
||||
class="card__image"
|
||||
/>
|
||||
<div class="card__content">
|
||||
<header class="card__header">
|
||||
<h2 class="card__title">
|
||||
<a href="projects/embroidery-viewer">
|
||||
{{ i18n "embroidery-viewer-title" }}
|
||||
</a>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="card__tags">
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #f73b01"
|
||||
></div>
|
||||
<div>Svelte</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="card__description">
|
||||
{{ i18n "embroidery-viewer-description" }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<img
|
||||
src="/img/lacine/card-screenshot.webp"
|
||||
width="450"
|
||||
alt="Embroidery viewer screenshot."
|
||||
class="card__image"
|
||||
/>
|
||||
<div class="card__content">
|
||||
<header class="card__header">
|
||||
<h2 class="card__title">
|
||||
<a href="projects/lacine">{{ i18n "lacine-title" }}</a>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="card__tags">
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #f73b01"
|
||||
></div>
|
||||
<div>SvelteKit</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card__description">{{ i18n "lacine-description" }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<img
|
||||
src="/img/entrepreneurship-alagoas/card-screenshot.webp"
|
||||
width="450"
|
||||
alt="Embroidery viewer screenshot."
|
||||
class="card__image"
|
||||
/>
|
||||
<div class="card__content">
|
||||
<header class="card__header">
|
||||
<h2 class="card__title">
|
||||
<a href="projects/entrepreneurship-alagoas">
|
||||
{{ i18n "entrepreneurship-alagoas-title" }}
|
||||
</a>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="card__tags">
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #f73b01"
|
||||
></div>
|
||||
<div>SvelteKit</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card__description">
|
||||
{{ i18n "entrepreneurship-alagoas-description" }}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="card">
|
||||
<img
|
||||
src="/img/itr/card-screenshot.webp"
|
||||
width="450"
|
||||
alt="Embroidery viewer screenshot."
|
||||
class="card__image"
|
||||
/>
|
||||
<div class="card__content">
|
||||
<header class="card__header">
|
||||
|
||||
<h2 class="card__title">
|
||||
<a href="projects/itr">
|
||||
{{ i18n "itr-title" }}
|
||||
</a>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<div class="card__tags">
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #f73b01"
|
||||
></div>
|
||||
<div>SvelteKit</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card__description">{{ i18n "itr-description" }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{{/* <article class="card">
|
||||
<img
|
||||
src="/img/iebtplus/screenshot.png"
|
||||
width="450"
|
||||
alt="Embroidery viewer screenshot."
|
||||
class="card__image"
|
||||
/>
|
||||
<div class="card__content">
|
||||
<header class="card__header">
|
||||
<h2 class="card__title">{{ i18n "iebtplus-title" }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="card__tags">
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #f73b01"
|
||||
></div>
|
||||
<div>SvelteKit</div>
|
||||
</div>
|
||||
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #4845fe"
|
||||
></div>
|
||||
<div>Strapi</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card__description">{{ i18n "iebtplus-description" }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<img
|
||||
src="/img/vumbora-startups/screenshot.png"
|
||||
width="450"
|
||||
alt="Embroidery viewer screenshot."
|
||||
class="card__image"
|
||||
/>
|
||||
<div class="card__content">
|
||||
<header class="card__header">
|
||||
<h2 class="card__title">{{ i18n "vumbora-startups-title" }}</h2>
|
||||
</header>
|
||||
|
||||
<div class="card__tags">
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #f73b01"
|
||||
></div>
|
||||
<div>SvelteKit</div>
|
||||
</div>
|
||||
|
||||
<div class="tag">
|
||||
<div
|
||||
class="tag__color"
|
||||
style="background-color: #4845fe"
|
||||
></div>
|
||||
<div>Strapi</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card__description">
|
||||
{{ i18n "vumbora-startups-description" }}
|
||||
</p>
|
||||
</div>
|
||||
</article> */}}
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{ partial "footer.html" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<footer>
|
||||
<div id="footer-icons">
|
||||
<a href="https://git.leomurca.xyz/" target="_blank" aria-label="My personal git server.">
|
||||
<div>{{ partial "svg" "git" }}</div>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/in/leonardoamurca/" target="_blank" aria-label="My personal LinkedIn profile.">
|
||||
<div>{{ partial "svg" "linkedin" }}</div>
|
||||
</a>
|
||||
<a href="{{ .Site.Home.RelPermalink }}posts/index.xml" target="_blank" aria-label="My RSS feed.">
|
||||
<div>{{ partial "svg" "rss" }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>Copyright © {{ now.Year }} Leonardo Murça. <br /> {{ i18n "all-rights-reserved"}} </p>
|
||||
</footer>
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<head>
|
||||
{{- $title := ( .Title ) -}}
|
||||
{{- $siteTitle := ( .Site.Title ) -}}
|
||||
{{- if .IsHome -}}
|
||||
<title>{{ $siteTitle }} | {{ i18n "home" }}</title>
|
||||
{{- else -}}
|
||||
<title>{{ $title }} - {{ $siteTitle }}</title>
|
||||
{{- end -}}
|
||||
{{ range .AlternativeOutputFormats -}}
|
||||
{{ printf `
|
||||
<link rel="%s" type="%s" href="%s" title="%s" />` .Rel .MediaType.Type .Permalink $.Site.Title | safeHTML }}
|
||||
{{ end -}}
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="description"
|
||||
content="{{ if $.IsHome }}{{ i18n $.Site.Params.description }}{{else}}{{$.Description}}{{end}}" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="author" content="Leonardo Murça" />
|
||||
<meta property="og:locale" content="{{ .Site.LanguageCode }}">
|
||||
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
|
||||
<meta property="og:title"
|
||||
content="{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} · {{ .Site.Title }}{{ end }}">
|
||||
<meta property="og:description"
|
||||
content="{{ if $.IsHome }}{{ i18n $.Site.Params.description }}{{else}}{{$.Description}}{{end}}" />
|
||||
<meta property="og:url" content="{{ .Permalink }}">
|
||||
<meta property="og:site_name" content="{{ .Site.Title }}">
|
||||
{{ if .Params.featured_image }}
|
||||
<meta property="og:image" content="{{ .Params.featured_image }}">
|
||||
<meta property="og:image:secure_url" content="{{ .Params.featured_image }}">
|
||||
{{- end }}
|
||||
{{ if .IsHome }}
|
||||
<meta property="og:image" content="{{ .Site.Params.featured_image }}">
|
||||
<meta property="og:image:secure_url" content="{{ .Site.Params.featured_image }}">
|
||||
{{- end }}
|
||||
{{ if isset .Params "date" }}
|
||||
<meta property="article:published_time" content="{{ (time .Date).Format " 2006-01-02T15:04:05Z" }}">{{ end }}
|
||||
<meta name="apple-mobile-web-app-title" content="Leonardo Murça" />
|
||||
<link rel="icon" type="image/png" href="/img/favicon/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/img/favicon/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/img/favicon/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/favicon/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/css/styles.css" />
|
||||
<link rel="preload" as="font">
|
||||
<script async src="https://hk.leomurca.xyz/hk.js"></script>
|
||||
</head>
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<header>
|
||||
<a href="#main" class="skip-to-main-content-link">Skip to main content</a>
|
||||
<a class="main-title" href="{{ .Site.Home.RelPermalink }}">
|
||||
<img src="/img/logo.svg" width="150" height="67" alt="Logotype" />
|
||||
</a>
|
||||
|
||||
<input aria-hidden="true" tabindex="-1" class="menu-btn" type="checkbox" id="menu-btn" />
|
||||
<label tabindex="-1" class="menu-icon" for="menu-btn">
|
||||
<span tabindex="-1" class="navicon"></span>
|
||||
</label>
|
||||
|
||||
|
||||
<nav class="menu">
|
||||
{{ range .Site.Menus.main }}
|
||||
<a class='nav-item' href="{{ .URL }}">{{ .Name }}</a>
|
||||
{{ end }}
|
||||
</nav>
|
||||
|
||||
{{/* {{ if eq "pt-br" .Site.LanguageCode}}
|
||||
<a class="common-switch english-switch" href="{{ .Site.BaseURL }}">
|
||||
<div style="display: flex; width: fit-content;">
|
||||
<div class="language-icon">{{ partial "svg" "language" }}</div>
|
||||
<span>English</span>
|
||||
</div>
|
||||
</a>
|
||||
{{ else }}
|
||||
<a class="common-switch portuguese-switch" href="{{ .Site.BaseURL }}/pt-br">
|
||||
<div style="display: flex; width: fit-content;">
|
||||
<div class="language-icon">{{ partial "svg" "language" }}</div>
|
||||
<span>Português</span>
|
||||
</div>
|
||||
</a>
|
||||
{{ end }} */}}
|
||||
|
||||
{{ $lang := .Site.Language.Lang }}
|
||||
{{ $isPT := eq $lang "pt-br" }}
|
||||
|
||||
{{ $currentPath := .RelPermalink }}
|
||||
{{ $targetPath := "" }}
|
||||
|
||||
{{ if $isPT }}
|
||||
{{/* Remove /pt-br prefix from path */}}
|
||||
{{ $targetPath = replace $currentPath "/pt-br" "" }}
|
||||
{{ else }}
|
||||
{{/* Add /pt-br prefix */}}
|
||||
{{ $targetPath = print "/pt-br" $currentPath }}
|
||||
{{ end }}
|
||||
|
||||
<a class="common-switch {{ if $isPT }}english-switch{{ else }}portuguese-switch{{ end }}"
|
||||
href="{{ $targetPath }}">
|
||||
<div style="display: flex; width: fit-content;">
|
||||
<div class="language-icon">{{ partial "svg" "language" }}</div>
|
||||
<span>{{ if $isPT }}English{{ else }}Português{{ end }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
|
||||
</header>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{{- $statusRaw := .Params.status | default "unknown" -}}
|
||||
{{- $status := lower $statusRaw -}}
|
||||
{{- $class := replace $status " " "-" -}}
|
||||
|
||||
<span class="project-status-label">Status</span>
|
||||
<span class="project-status project-status--{{ $class }}">
|
||||
{{ i18n (print "projectStatus_" $status) | default (title $statusRaw) }}
|
||||
</span>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{{ $svg := . }}
|
||||
{{ $class := print $svg "-icon" }}
|
||||
{{ $match := "<svg (.*)?>(.*)</svg>" }}
|
||||
|
||||
|
||||
{{ $replaceWith := printf `<svg class="%s" ${1}>${2}</svg>` $class }}
|
||||
{{ return (replaceRE $match $replaceWith (printf "/static/svg/%s.svg" $svg | readFile) | safeHTML) }}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{{ $imgPath := delimit (slice "static" (.Destination | safeURL) ) "/"}}
|
||||
|
||||
{{ if fileExists $imgPath }}
|
||||
|
||||
{{ $img := imageConfig $imgPath }}
|
||||
|
||||
<picture>
|
||||
<img src="{{ .Destination | safeURL }}" width="{{ $img.Width }}" height="{{ $img.Height }}" alt="{{ .Text }}" {{ with
|
||||
.Title}} title="{{ . }}" {{ end }} />
|
||||
<figcaption>Fig. {{ substr (strings.TrimSuffix (path.Ext .Destination) .Destination) -1 }} - {{ .Text }}.</figcaption>
|
||||
</picture>
|
||||
|
||||
{{ else }}
|
||||
|
||||
{{ errorf "Specified file at %s not found." $imgPath }}
|
||||
|
||||
{{ end }}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{{ define "main" }}
|
||||
<main id="main">
|
||||
<article>
|
||||
<h1 class="title">{{ .Title }}</h1>
|
||||
<p class="posted-on"><strong>{{ i18n "posted-on"}}</strong>{{ dateFormat (i18n "single-post-date-format") .Date }}</p>
|
||||
{{ if ne .Date .Lastmod }}
|
||||
<p class="last-update"><strong>{{ i18n "last-update"}}</strong>{{ dateFormat (i18n "single-post-date-format") .Lastmod }}</p>
|
||||
{{ end }}
|
||||
<section class="body">
|
||||
{{ .Content }}
|
||||
</section>
|
||||
</article>
|
||||
</main>
|
||||
{{ end }}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
{{ define "main" }}
|
||||
<main id="main">
|
||||
<article class="project-container">
|
||||
<div class="video-container">
|
||||
<video
|
||||
src="{{ .Params.lead_video }}"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
style="width: 100%; height: auto"
|
||||
></video>
|
||||
<div class="video-overlay">
|
||||
<h1 class="project-title">{{ .Title }}</h1>
|
||||
<p>
|
||||
{{ .Description }}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
{{ partial "project-status.html" . }}
|
||||
</div>
|
||||
<section class="body">{{ .Content }}</section>
|
||||
</article>
|
||||
</main>
|
||||
{{ end }}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<img class="icon" src="/icons/{{ .Get "src" }}"
|
||||
{{- with .Get "alt" }} alt="{{.}}"{{ end -}}
|
||||
>
|
||||
12
leomurca/.gitignore
vendored
|
|
@ -1,12 +0,0 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
deploy.key
|
||||
.vscode
|
||||
|
|
@ -1 +0,0 @@
|
|||
# leomurca
|
||||
3292
leomurca/package-lock.json
generated
3292
package-lock.json
generated
Normal file
0
leomurca/src/app.d.ts → src/app.d.ts
vendored
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 5.7 MiB After Width: | Height: | Size: 5.7 MiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
7
src/lib/config/imageCdn.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* CDN base for `/t/...` image paths — set `PUBLIC_IMAGE_BASE_URL` in `.env` (see `.env.example`).
|
||||
* Uses `$env/static/public` so Vite/SvelteKit always inlines the value (avoids empty base → `/t/...` 404s on localhost).
|
||||
*/
|
||||
import { PUBLIC_IMAGE_BASE_URL } from '$env/static/public';
|
||||
|
||||
export const imageBaseUrl = PUBLIC_IMAGE_BASE_URL.replace(/\/$/, '');
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { PUBLIC_IMAGE_BASE_URL } from '$env/static/public';
|
||||
import { imageBaseUrl } from '$lib/config/imageCdn.js';
|
||||
import { locale, t } from '$lib/translations';
|
||||
|
||||
/** Career start year for experience calculation. */
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
const aboutPayload = $derived({ years, sinceYear: START_YEAR, yearNoun });
|
||||
|
||||
/** Portrait for the About section (add `leomurca/leonardo-about.webp` to your image CDN). */
|
||||
const portraitSrc = `${PUBLIC_IMAGE_BASE_URL}/t/f_webp,w_960,h_1200,c_fill/leomurca/me.webp`;
|
||||
const portraitSrc = `${imageBaseUrl}/t/f_webp,w_960,h_1200,c_fill/leomurca/me.webp`;
|
||||
</script>
|
||||
|
||||
<section id="about" class="about" aria-labelledby="about-title">
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { resolve } from '$app/paths';
|
||||
import { PUBLIC_IMAGE_BASE_URL } from '$env/static/public';
|
||||
import { imageBaseUrl } from '$lib/config/imageCdn.js';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
/** @type {readonly { titleKey: string; altKey: string; image: string; route: '/embroidery-viewer' | '/entrepreneurship-alagoas' | '/implant-file' }[]} */
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
<a href={resolve(work.route)}>
|
||||
<img
|
||||
class="featured__image"
|
||||
src={`${PUBLIC_IMAGE_BASE_URL}/t/f_webp/leomurca/${work.image}`}
|
||||
src={`${imageBaseUrl}/t/f_webp/leomurca/${work.image}`}
|
||||
alt={$t(work.altKey)}
|
||||
/>
|
||||
<p class="featured__label">{$t(work.titleKey)}</p>
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
<script>
|
||||
/* eslint-disable svelte/no-at-html-tags -- copy comes from static locale JSON */
|
||||
import { PUBLIC_IMAGE_BASE_URL } from '$env/static/public';
|
||||
import { imageBaseUrl } from '$lib/config/imageCdn.js';
|
||||
import { isMobile } from '$lib/utils/isMobile';
|
||||
import { t } from '$lib/translations';
|
||||
|
||||
const backgroundImage = isMobile()
|
||||
? `${PUBLIC_IMAGE_BASE_URL}/t/f_webp/leomurca/hero-mobile.webp`
|
||||
: `${PUBLIC_IMAGE_BASE_URL}/t/f_webp,w_1920,h_1080/leomurca/hero.webp`;
|
||||
? `${imageBaseUrl}/t/f_webp/leomurca/hero-mobile.webp`
|
||||
: `${imageBaseUrl}/t/f_webp,w_1920,h_1080/leomurca/hero.webp`;
|
||||
</script>
|
||||
|
||||
<section id="hero" style={`background-image: url(${backgroundImage});`}>
|
||||