diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..487dc3b --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,115 @@ +name: Android + +# Release do Android por tag semver. Assina o AAB com a upload key (secret) e +# envia direto para o track `internal` da Play — o AAB assinado nunca vira +# artifact público. A promoção para `closed`/produção é manual no Console. +# +# Secrets: ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, +# ANDROID_KEY_PASSWORD, PLAY_SERVICE_ACCOUNT_JSON. Ver docs/PLAY-STORE.md. +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: android-${{ github.ref }} + cancel-in-progress: false + +jobs: + bundle: + name: AAB assinado → Play (internal) + runs-on: ubuntu-latest + env: + # `secrets` não pode ser usado em `if`; o env no job pode. + PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: npm + - run: npm ci + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + # O runner do ubuntu já traz o SDK; `android-actions/setup-android` + # quebra ao tentar instalar o pacote `tools` com o cmdline-tools novo. + - name: Android SDK + NDK + run: | + export ANDROID_HOME="${ANDROID_HOME:-/usr/local/lib/android/sdk}" + sdkmgr="$(command -v sdkmanager || true)" + if [ -z "$sdkmgr" ]; then + sdkmgr="$(find "$ANDROID_HOME/cmdline-tools" /usr/local/lib/android/sdk/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -n 1)" + fi + if [ -z "$sdkmgr" ]; then + echo "sdkmanager não encontrado no runner" >&2 + exit 1 + fi + echo "sdkmanager: $sdkmgr" + yes | "$sdkmgr" --licenses > /dev/null + "$sdkmgr" --install "platforms;android-36" "build-tools;36.0.0" "ndk;27.0.12077973" + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "NDK_HOME=$ANDROID_HOME/ndk/27.0.12077973" >> "$GITHUB_ENV" + + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - name: Versão da tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + if ! printf '%s' "$tag" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Tag '$GITHUB_REF_NAME' fora do padrão vX.Y.Z" >&2 + exit 1 + fi + node tools/version.mjs check "$tag" + echo "ANDROID_TAG_VERSION=$tag" >> "$GITHUB_ENV" + + - name: Restaurar keystore de upload + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + if [ -z "$KEYSTORE_BASE64" ]; then + echo "Sem ANDROID_KEYSTORE_BASE64: build sem assinatura." >&2 + exit 0 + fi + mkdir -p "$RUNNER_TEMP/keystore" + printf '%s' "$KEYSTORE_BASE64" | base64 -d > "$RUNNER_TEMP/keystore/upload.jks" + echo "ANDROID_KEYSTORE_PATH=$RUNNER_TEMP/keystore/upload.jks" >> "$GITHUB_ENV" + + - name: Build do AAB + env: + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: npx tauri android build --aab --ci + + - name: Conferir o AAB + run: | + aab=$(find src-tauri/gen/android/app/build/outputs/bundle -name '*.aab' | head -n 1) + if [ -z "$aab" ]; then + echo "Nenhum AAB gerado" >&2 + exit 1 + fi + echo "AAB: $aab ($(du -h "$aab" | cut -f1))" + + - name: Enviar para o track internal + if: ${{ startsWith(github.ref, 'refs/tags/v') && env.PLAY_SERVICE_ACCOUNT_JSON != '' }} + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJson: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.reinaldoassis.atlas + releaseFiles: src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab + track: internal + status: completed diff --git a/VERSION b/VERSION index 1c09c74..1d0ba9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.3 +0.4.0 diff --git a/docs/ANDROID-BETA.md b/docs/ANDROID-BETA.md new file mode 100644 index 0000000..9a10f1c --- /dev/null +++ b/docs/ANDROID-BETA.md @@ -0,0 +1,154 @@ +# Atlas — beta Android na Google Play + +Decisões e plano da primeira distribuição do Atlas no Android. Vale para a +série **0.4.x**; o PC (macOS/Windows) continua **100% grátis**. + +> Estado: decisões fechadas em 21/10/2026; código entregue no PR #46. Fatias +> marcadas ✅ já entregues. + +## Estado em 22/10/2026 + +**Código** — PR [#46](https://github.com/ReinaldoAssis/Atlas/pull/46), 9 commits, +CI principal 4/4 verde (frontend ubuntu/windows, rust, instalador Windows). +O workflow `Android` (`workflow_dispatch`/tag) está em validação piloto no CI +(último run: SDK/NDK resolvidos; build do AAB em andamento). + +**Verificado localmente (Mac)** + +| Item | Resultado | +|---|---| +| `npm run check` | 0 erros / 0 warnings | +| `npm test` | 264 testes | +| `npm run build` | ok | +| `npm run csp:check` | 23/23 (inclui boot em `en`) | +| `cargo test` | 50 testes | +| APK debug aarch64 | `com.reinaldoassis.atlas.debug`, versionCode 400, minSdk 29, target 36, `MulticastLockPlugin` e permissão `BILLING` presentes | +| AAB release | gerado e assinado com keystore descartável (valida o signing config do Gradle) | + +**Não verificado** — nada disto tem device/Play por trás ainda: boot e sync no +aparelho, limites do free na prática, compra/restore no sandbox, upload real no +track `internal` (sem secrets configurados). + +**Ambiente local usado** (reproduzir em outra máquina): JDK 17 +(`brew install openjdk@17`), `cmdline-tools` em `~/Library/Android/sdk`, +NDK `27.0.12077973`, targets Rust `aarch64-linux-android`, +`armv7-linux-androideabi`, `x86_64-linux-android`. + +## Decisões travadas + +| Tema | Decisão | +|---|---| +| Application ID | `com.reinaldoassis.atlas` (todas as plataformas) | +| `minSdk` / `targetSdk` | **29** / **36** (Play exige 36 desde 31/08/2026) | +| `versionCode` | `major*10000 + minor*100 + patch` (0.4.0 → 400) | +| Canais | `internal` (até 100) + `closed` (12 testers) em paralelo | +| Produção | conta **pessoal nova** → closed test com 12 testers opt-in por 14 dias | +| Assinatura | upload key local + secret do CI; **Play App Signing** ligado; AAB nunca vira artifact público | +| Pipeline | tag `v*` → job ubuntu assina e sobe o AAB no track `internal`; promoção pra `closed` manual no Console | +| Billing | `tauri-plugin-iap` (Play Billing 9, MIT), Android agora e iOS depois | +| Produtos | mensal **R$ 12,50**, anual **R$ 97**, trial **7 dias** | +| Entitlement | cache local no SQLite + reconsulta à Play em launch/resume + **grace de 7 dias offline**; checagem em Rust | +| Gating | limites compilados só para mobile (`cfg(android/ios)`); PC sem gating; backstop em Rust no `db_batch` | +| Sync | entra na beta; descoberta via `mdns-sd` + `MulticastLock` (plugin Kotlin) | +| Lembretes | paridade com iOS (`POST_NOTIFICATIONS`, alarme exato no Android 12+) | +| Idiomas | pt-BR (base) + en; seletor Sistema / Português / English; fallback **en** fora de pt/en | +| Política | `atlas.reinaldoassis.com/privacy` — texto pronto em `docs/PRIVACY.md`; publicação no site pendente | +| Assets da ficha | depois do código e do billing | + +## Free vs Pro (mobile) + +PC não tem limite nem paywall. + +| Item | Free | Pro | +|---|---|---| +| Notas ativas | 7 | ilimitado | +| Anexos | 5 | ilimitado | +| Hábitos ativos | 3 | ilimitado | +| Trackers ativos | 2 | ilimitado | +| Histórico (hábitos/trackers) | últimos 7 dias | completo | +| Nós do grafo | 5 (origem não conta) | ilimitado | +| Categorias de finanças | 4 custom | ilimitado | +| Categorias de tempo | 5 custom | ilimitado | +| Sync entre aparelhos | — | ✅ | +| Backup manual | ✅ | ✅ | + +O seed de categorias cabe nos limites: finanças = Salário, Moradia, +Alimentação e Lazer; tempo = Foco profundo, Leitura, Saúde, Pessoal e +Descanso. `Transporte` e `Administração` saíram do seed (recriáveis como +categoria custom) para valer também no PC. + +## Fatias + +1. **Fundação** ✅ — versão 0.4.0, `tauri android init`, JDK 17 + NDK 27, + build debug no device, identifier/minSdk/targetSdk ajustados. +2. **Sync Android** ✅ — plugin Kotlin com `MulticastLock`, `sync` compilando + no Android, pareamento a validar em device real. +3. **i18n completo** ✅ — pt-BR + en, seletor nas Configurações, migração de + toda a copy (markup, atributos, toasts, erros), `Intl` por locale, guard + sem allowlist. Ver `docs/I18N.md`. +4. **Gating + entitlement** ✅ — `plan.rs` valida os limites dentro da + transação do `db_batch` (rollback + `plan-limit::`), cache de + Pro com grace de 7 dias, guard otimista na UI e paywall (sheet no limite + + linha em Configurações). Histórico do free corta em 7 dias na lista. +5. **Billing** ✅ — `tauri-plugin-iap` (Billing 9) só no mobile, preços da + loja com fallback, compra/restore/acknowledge e renovação via listener. + Falta criar os produtos na Play e testar com license tester. +6. **Pipeline de release** ✅ — workflow `Android` em tag, upload key pelos + secrets, AAB direto pro track `internal`, `versionCode` conferido. +7. **Loja e site** — textos da ficha e política prontos (`docs/STORE-LISTING.md`, + `docs/PRIVACY.md`); falta publicar no site, produzir assets e preencher os + formulários da Play. + +## Checklist manual (fora do código) + +Ordem prática; detalhes em `docs/PLAY-STORE.md` e `docs/RELEASES.md`. + +**1. Conta e canais** +- [ ] Conta pessoal no Play Console (US$ 25) e app `com.reinaldoassis.atlas`. +- [ ] Track `internal` liberado. +- [ ] Recrutar **12+ testers** com conta Google e abrir o **closed test** + (14 dias corridos com todos opt-in; internal não conta). + +**2. Assinatura e automação** +- [ ] Gerar a upload key (`keytool -genkeypair …`) e guardar `.jks` + senhas. +- [ ] Ligar **Play App Signing** no Console. +- [ ] Cadastrar os secrets: `ANDROID_KEYSTORE_BASE64`, + `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. +- [ ] Conta de serviço da Play Developer API → `PLAY_SERVICE_ACCOUNT_JSON`. +- [ ] Rodar o workflow `Android` com os secrets e conferir o AAB no track + `internal` (build local já validado). + +**3. Produtos** +- [ ] `atlas_pro_monthly` — R$ 12,50/mês, trial 7 dias. +- [ ] `atlas_pro_annual` — R$ 97/ano, trial 7 dias. +- [ ] License testers + testar compra, acknowledge, restore e cancelamento. + +**4. Device** +- [ ] Instalar o APK debug e testar boot, i18n (troca pt/en), limites do free + e o paywall. +- [ ] Parear Android ↔ desktop e Android ↔ Android na mesma rede (sync). +- [ ] Conferir lembretes locais e o pedido de `POST_NOTIFICATIONS`. + +**5. Loja** +- [ ] Publicar a política em `https://atlas.reinaldoassis.com/privacy` + (texto em `docs/PRIVACY.md`). +- [ ] Ficha pt-BR/en (`docs/STORE-LISTING.md`), segurança de dados, + classificação etária e declaração de anúncios. +- [ ] Produzir assets: ícone 512, feature graphic 1024×500, screenshots. + +## Riscos + +- **Assinatura e GPL**: o app é open source; quem compilar por conta própria + contorna o gating. O objetivo é deter o usuário comum, não impedir fork. +- **Sync no Android**: multicast com `MulticastLock` cai em economia de energia; + sync continua só com o app aberto, como no desktop. +- **Play Billing**: reembolso/cancelamento só chegam via reconsulta; a grace de + 7 dias é o que evita bloquear usuário legítimo sem rede. O plugin + `tauri-plugin-iap` está em RC e pinado por versão exata; subir de versão exige + testar compra/restore de novo. +- **Troca de identificador**: `com.reinaldoassis.atlas` substitui o antigo + `com.randroi75.atlas`, então instalações de teste de desktop/iOS começam com + appdata/bundle novos. Dados antigos só voltam por import de backup `.zip`. +- **Keystore**: perder a upload key não derruba a distribuição (o Play App + Signing permite rotacionar), mas o AAB precisa ser reenviado com a chave nova; + guarde o `.jks` e as senhas fora do repositório. diff --git a/docs/I18N.md b/docs/I18N.md new file mode 100644 index 0000000..686f3c4 --- /dev/null +++ b/docs/I18N.md @@ -0,0 +1,59 @@ +# i18n — dois idiomas, sem reload + +Atlas fala **pt-BR** (base) e **en**. A escolha do usuário vive no aparelho; +não existe rota `/pt-BR/...`, cookie nem servidor. + +## Como funciona + +| Peça | Onde | +|---|---| +| Mensagens | `messages/pt-BR.json`, `messages/en.json` | +| Locales e estratégia | `project.inlang/settings.json`, `project.inlang/paraglide.config.ts` | +| Resolução (pura, testada) | `src/lib/i18n/locale.ts` | +| Estado reativo e troca | `src/lib/i18n/locale.svelte.ts` (`i18n.locale`, `setLocaleChoice`) | +| `Intl` (data/moeda) | `src/lib/i18n/intl.ts` (`intlLocale()`) | +| Guard contra copy solta | `src/lib/i18n/hardcoded.test.ts` | + +Regras de resolução, em ordem: +1. escolha salva (`atlas.locale.mode`) — `pt-BR`, `en` ou `system`; +2. idioma do sistema: `pt*` → pt-BR, **qualquer outra língua → en**; +3. `baseLocale` (pt-BR) é só fallback de chave ausente. + +Trocar o idioma **não recarrega** a página: `+page.svelte` envolve o app num +`{#key i18n.locale}`. As funções `m.*()` do Paraglide não são reativas, então a +remontagem é o que faz a árvore inteira reler as mensagens. + +## Escrevendo copy + +- Toda string visível vem de `m.chave()`. Isso inclui `placeholder`, + `aria-label`, `title`, `alt`, toasts, erros e estados vazios. +- Chaves em `snake_case`, prefixadas pela área: `settings_`, `fin_`, `notes_`, + `graph_`, `trash_`, `common_`… +- Interpolação sempre na mensagem (`"synced {minutes} min ago"`), nunca por + concatenação. +- Plurais: duas chaves (`*_one` / `*_many`) escolhidas por mensagem inteira. +- **Não** entram no catálogo: números, símbolos, unidades (`kg`, `min`), ids, + classes CSS, nomes próprios ("Atlas"), datas e dados do usuário. +- Dados criados pelo app (categorias, domínios do grafo, rótulos sim/não) são + semeados no idioma do primeiro boot e ficam gravados; trocar o idioma depois + **não** renomeia dados existentes. + +## Guard + +`npm test` roda `hardcoded.test.ts`: qualquer nó de texto/atributo com copy em +arquivo fora de `hardcoded-allowlist.json` quebra o CI. Ao migrar uma tela, a +lista encolhe; para regravá-la use: + +```sh +UPDATE_I18N_ALLOWLIST=1 npx vitest run src/lib/i18n/hardcoded.test.ts +``` + +Nunca use o comando acima para "consertar" uma tela nova — ele esconde o +problema em vez de traduzir. + +## Limitações conhecidas + +- Strings dentro de `
{fromName} → {toName}
- onpatch({ label: event.currentTarget.value })} /> -
Alimentado por
+ onpatch({ label: event.currentTarget.value })} /> +
{m.graph_edge_powered_by()}
{#each GRAPH_SOURCES as source (source.id)} - + {/each}
{#if edge.source === 'manual'}
= 1}>{Math.round(edgeRatio(edge) * 100)}%
-
Progresso
+
{m.graph_edge_progress()}
{:else} {#if sourceNeedsRef(edge.source)} -
Referência
+
{m.graph_edge_reference()}
{#if options.length}
{#each options as option (option.id)} @@ -82,37 +83,39 @@ {/each}
{:else} -
Nada para referenciar neste módulo ainda.
+
{m.graph_edge_no_refs()}
{/if} {/if} {#if sourceWindowable(edge.source)} -
Janela
+
{m.graph_edge_window()}
{#each GRAPH_WINDOWS as window (window.id)} - + {/each}
{#if edge.window === 'fixed'} - onpatch({ windowFrom: event.currentTarget.value })} /> + onpatch({ windowFrom: event.currentTarget.value })} /> {/if} {/if} -
Meta ({unit})
+
{m.graph_edge_target_with_unit({ unit })}
onTargetInput(event.currentTarget.value)} />
{#if progress} - {formatMetric(progress.value)} de {formatMetric(progress.target)} · {Math.round(Math.min(1, progress.ratio) * 100)}%{edge.source === 'financeLimit' ? ' disponível' : ''} + {formatMetric(progress.value)} {edge.source === 'financeLimit' + ? m.graph_edge_readout_available({ target: formatMetric(progress.target), percent: Math.round(Math.min(1, progress.ratio) * 100) }) + : m.graph_edge_readout({ target: formatMetric(progress.target), percent: Math.round(Math.min(1, progress.ratio) * 100) })} {:else} - Fonte indisponível no momento. + {m.graph_edge_source_unavailable()} {/if}
{/if} - - + + diff --git a/src/lib/components/graph/GraphList.svelte b/src/lib/components/graph/GraphList.svelte index 3f5d27e..26c9e33 100644 --- a/src/lib/components/graph/GraphList.svelte +++ b/src/lib/components/graph/GraphList.svelte @@ -1,5 +1,6 @@ -
+
{#each graph.nodes as node (node.id)} + {@const roleLabel = node.origin ? m.graph_origin_title() : nodeRoleLabel(node)}
{node.name} - {node.origin ? 'Origem' : nodeRoleLabel(node)} · {horizonLabel(node.horizon)} · {domainName(node.domainId)}{reached(node) ? ' · alcançado' : ''} + {reached(node) + ? m.graph_list_meta_reached({ role: roleLabel, horizon: horizonLabel(node.horizon), domain: domainName(node.domainId) }) + : m.graph_list_meta({ role: roleLabel, horizon: horizonLabel(node.horizon), domain: domainName(node.domainId) })}
{#each incoming(node.id) as edge (edge.id)}
- ↳ de : {edge.label} · {Math.round(edgeRatio(edge) * 100)}% - + ↳ {m.graph_list_edge_from()} : {edge.label} · {Math.round(edgeRatio(edge) * 100)}% +
{/each} {#each outgoing(node.id) as edge (edge.id)}
- ↳ para : {edge.label} · {Math.round(edgeRatio(edge) * 100)}% + ↳ {m.graph_list_edge_to()} : {edge.label} · {Math.round(edgeRatio(edge) * 100)}%
{/each}
{/each} - +
diff --git a/src/lib/components/graph/GraphNodeSheet.svelte b/src/lib/components/graph/GraphNodeSheet.svelte index 2851d4e..552bb6f 100644 --- a/src/lib/components/graph/GraphNodeSheet.svelte +++ b/src/lib/components/graph/GraphNodeSheet.svelte @@ -1,6 +1,7 @@ -
{node.origin ? 'Origem do grafo' : `${nodeRoleLabel(node)} · ${horizonLabel(node.horizon)}`}
+
{node.origin ? m.graph_node_sheet_origin() : `${nodeRoleLabel(node)} · ${horizonLabel(node.horizon)}`}
onpatch({ name: event.currentTarget.value })} /> -
Horizonte
+
{m.graph_node_horizon()}
{#each HORIZONS as horizon (horizon.id)} - + {/each}
{#if !node.origin}
- {nodeRoleLabel(node)} · {isMilestone(node) ? 'longo prazo' : 'curto prazo'} + {nodeRoleLabel(node)} · {isMilestone(node) ? m.graph_node_long_term() : m.graph_node_short_term()}
{/if} -
Domínio
+
{m.graph_node_domain()}
{#each domains as domain (domain.id)} {/each}
{#if !node.origin && !hasIncoming} - + {/if} - + {#if !node.origin} - + {/if} diff --git a/src/lib/components/graph/GraphView.svelte b/src/lib/components/graph/GraphView.svelte index c8cffc1..f3b2e4b 100644 --- a/src/lib/components/graph/GraphView.svelte +++ b/src/lib/components/graph/GraphView.svelte @@ -18,6 +18,8 @@ import GraphNodeSheet from './GraphNodeSheet.svelte'; import GraphEdgeSheet from './GraphEdgeSheet.svelte'; import GraphList from './GraphList.svelte'; + import { m } from '#lib/paraglide/messages.js'; + import { allowCreate } from '#lib/plan.svelte'; let { snapshot, @@ -101,7 +103,7 @@ } [523, 659, 784, 1047].forEach((frequency, index) => setTimeout(() => playTone(frequency, 0.24, 'triangle', 0.06, sound), index * 90)); pulseHaptic(haptics, [20, 40, 20, 40, 70]); - ontoast(`Transição concluída: ${editEdge.label}`); + ontoast(m.graph_toast_edge_done({ label: editEdge.label })); } } @@ -148,17 +150,18 @@ } function openNewNode(x: number, y: number, from?: string) { + if (!allowCreate('graph_nodes', graph.nodes.filter((node) => !node.origin).length)) return; const origin = graph.nodes.find((node) => node.origin); const node: GraphNode = { id: graphId(), - name: 'Novo nó', + name: m.graph_new_node(), horizon: 'quarter', domainId: origin?.domainId ?? graph.domains[0]?.id ?? '', x, y }; const edges = from - ? [...graph.edges, { id: graphId(), from, to: node.id, label: 'Nova ação', source: 'manual' as const, target: 0, window: 'month' as const, manualProgress: 0 }] + ? [...graph.edges, { id: graphId(), from, to: node.id, label: m.graph_new_action(), source: 'manual' as const, target: 0, window: 'month' as const, manualProgress: 0 }] : graph.edges; commit({ ...graph, nodes: [...graph.nodes, node], edges }); editNode = node; @@ -184,11 +187,11 @@ function connect(from: string, to: string) { const origin = graph.nodes.find((node) => node.origin); - if (from === to) return ontoast('Um nó não pode se ligar a si mesmo'); - if (origin && to === origin.id) return ontoast('O nó de origem não recebe conexões'); - if (graph.edges.some((edge) => edge.from === from && edge.to === to)) return ontoast('Essa transição já existe'); - if (wouldCreateCycle(graph.edges, from, to)) return ontoast('Isso criaria um ciclo'); - const edge: GraphEdge = { id: graphId(), from, to, label: 'Nova ação', source: 'manual', target: 0, window: 'month', manualProgress: 0 }; + if (from === to) return ontoast(m.graph_toast_self_link()); + if (origin && to === origin.id) return ontoast(m.graph_toast_origin_no_incoming()); + if (graph.edges.some((edge) => edge.from === from && edge.to === to)) return ontoast(m.graph_toast_edge_exists()); + if (wouldCreateCycle(graph.edges, from, to)) return ontoast(m.graph_toast_cycle()); + const edge: GraphEdge = { id: graphId(), from, to, label: m.graph_new_action(), source: 'manual', target: 0, window: 'month', manualProgress: 0 }; commit({ ...graph, edges: [...graph.edges, edge] }); const target = findNode(to); if (target) { @@ -209,7 +212,7 @@ commit({ ...graph, nodes: graph.nodes.filter((item) => item.id !== id), edges: graph.edges.filter((edge) => edge.from !== id && edge.to !== id) }); playFeedback('erase', sound); pulseHaptic(haptics, 12); - ontoast('Nó removido', () => commit(JSON.parse(before) as GraphState)); + ontoast(m.graph_toast_node_removed(), () => commit(JSON.parse(before) as GraphState)); } function deleteSelectedNode() { @@ -224,12 +227,12 @@ commit({ ...graph, edges: graph.edges.filter((edge) => edge.id !== id) }); playFeedback('erase', sound); pulseHaptic(haptics, 12); - ontoast('Transição removida', () => commit(JSON.parse(before) as GraphState)); + ontoast(m.graph_toast_edge_removed(), () => commit(JSON.parse(before) as GraphState)); } function resetDemo() { commit(createGraphFromDemo()); - ontoast('Grafo de exemplo restaurado.'); + ontoast(m.graph_toast_demo_reset()); } onMount(() => { @@ -283,16 +286,16 @@
-

Planejamento

- {nodeCount} nós · {edgeCount} transições +

{m.graph_title()}

+ {m.graph_counts({ nodes: nodeCount, edges: edgeCount })}
- - + +
-
ObjetivoMarco
+
{m.graph_role_goal()}{m.graph_role_milestone()}
- +
{#if showList} @@ -320,7 +323,7 @@ {#if editNode} {@const node = editNode} - + + void } | null>(null); let pending = $state(null); - const weekLabels = $derived(firstDay === 'sun' ? ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'] : ['S', 'T', 'Q', 'Q', 'S', 'S', 'D']); + const weekLabels = $derived( + firstDay === 'sun' + ? [ + m.habit_weekday_sun(), + m.habit_weekday_mon(), + m.habit_weekday_tue(), + m.habit_weekday_wed(), + m.habit_weekday_thu(), + m.habit_weekday_fri(), + m.habit_weekday_sat() + ] + : [ + m.habit_weekday_mon(), + m.habit_weekday_tue(), + m.habit_weekday_wed(), + m.habit_weekday_thu(), + m.habit_weekday_fri(), + m.habit_weekday_sat(), + m.habit_weekday_sun() + ] + ); const days = $derived.by(() => { const first = new Date(`${month}T12:00:00`); const offset = firstDay === 'sun' ? first.getDay() : (first.getDay() + 6) % 7; @@ -45,8 +66,8 @@ { @@ -57,11 +78,11 @@ }} >
- {monthLabel(month)} -
@@ -69,7 +90,7 @@ -
+
{#each days as date} + diff --git a/src/lib/components/settings/SettingsSheet.svelte b/src/lib/components/settings/SettingsSheet.svelte index 9429fec..ab4272a 100644 --- a/src/lib/components/settings/SettingsSheet.svelte +++ b/src/lib/components/settings/SettingsSheet.svelte @@ -1,11 +1,14 @@ - onopenfull(note)}> + onopenfull(note)}> {#if note.tags.length}
{#each note.tags as tag (tag)}#{tag}{/each} @@ -41,7 +42,7 @@ {#if note.body.trim()}
{:else} -

Nota vazia.

+

{m.planner_note_empty()}

{/if} diff --git a/src/lib/components/time/PlannerNotesSheet.svelte b/src/lib/components/time/PlannerNotesSheet.svelte index 490ba0d..35b2e42 100644 --- a/src/lib/components/time/PlannerNotesSheet.svelte +++ b/src/lib/components/time/PlannerNotesSheet.svelte @@ -1,5 +1,6 @@ - +
{#each items as item, index (item.note.id + index)} {/each}
diff --git a/src/lib/components/time/TimeRangeSheet.svelte b/src/lib/components/time/TimeRangeSheet.svelte index 35a8307..2096d0d 100644 --- a/src/lib/components/time/TimeRangeSheet.svelte +++ b/src/lib/components/time/TimeRangeSheet.svelte @@ -3,6 +3,8 @@ import { ChevronLeft, ChevronRight } from '@lucide/svelte'; import Sheet from '#lib/components/ui/Sheet.svelte'; import { localDate } from '#lib/atlas'; + import { m } from '#lib/paraglide/messages.js'; + import { intlLocale } from '#lib/i18n/intl'; import { clampRange, defaultRange, @@ -54,7 +56,7 @@ }); function dayLabel(date: string): string { - return new Date(`${date}T12:00:00`).toLocaleDateString('pt-BR', { + return new Date(`${date}T12:00:00`).toLocaleDateString(intlLocale(), { weekday: 'long', day: 'numeric', month: 'long', @@ -82,7 +84,7 @@ pendingEnd = result.end; if (result.clamped) { pendingApply = { start: result.start, end: result.end }; - warning = `Máximo de ${TIME_RANGE_MAX_DAYS} dias. Toque em Aplicar.`; + warning = m.planner_range_warning({ days: TIME_RANGE_MAX_DAYS }); return; } onapply(result.start, result.end); @@ -107,19 +109,19 @@ } - +
- - - máx. {TIME_RANGE_MAX_DAYS} dias + + + {m.planner_range_max({ days: TIME_RANGE_MAX_DAYS })}
- {monthLabel(month)} -
@@ -128,7 +130,7 @@ {#each ['S', 'T', 'Q', 'Q', 'S', 'S', 'D'] as label, index (index)}{label}{/each}
-
+
{#each cells as date, index (date ?? `blank-${index}`)} {#if date} + {/if}
{:else} -

Toque no primeiro e no último dia.

+

{m.planner_range_hint()}

{/if} diff --git a/src/lib/components/trash/TrashSheet.svelte b/src/lib/components/trash/TrashSheet.svelte index 9aeba90..ab6d7a6 100644 --- a/src/lib/components/trash/TrashSheet.svelte +++ b/src/lib/components/trash/TrashSheet.svelte @@ -1,6 +1,8 @@ - +
-

Itens ficam aqui por {TRASH_RETENTION_DAYS} dias. Restaurar devolve tudo que foi excluído junto.

+

{m.trash_hint({ days: TRASH_RETENTION_DAYS })}

-
+
{#each FILTERS as item} {/each}
@@ -130,8 +132,8 @@ {#if groups.length === 0}
- Lixeira vazia -

{filter === 'all' ? 'Nada foi excluído nos últimos dias.' : 'Nada excluído nesta categoria.'}

+ {m.trash_empty_title()} +

{filter === 'all' ? m.trash_empty_all() : m.trash_empty_filtered()}

{:else} {#each groups as group} @@ -141,16 +143,16 @@ {@const Icon = iconOf(event.module)}
- {KIND_LABEL[event.kind] ?? event.kind} + {KIND_LABEL[event.kind]?.() ?? event.kind} {event.label} - {MODULE_LABEL[event.module]} · {remaining(event)} dia{remaining(event) === 1 ? '' : 's'} restante{remaining(event) === 1 ? '' : 's'} + {MODULE_LABEL[event.module]()} · {remaining(event) === 1 ? m.trash_day_left({ count: remaining(event) }) : m.trash_days_left({ count: remaining(event) })}
- -
diff --git a/src/lib/components/ui/ValueSheet.svelte b/src/lib/components/ui/ValueSheet.svelte index bbbb4cf..49313f4 100644 --- a/src/lib/components/ui/ValueSheet.svelte +++ b/src/lib/components/ui/ValueSheet.svelte @@ -1,5 +1,6 @@ - Atlas — seu espaço de vida - + {m.shell_head_title()} + +{#key i18n.locale} {#if !ready} -
A
Preparando seu Atlas…
+
A
{m.shell_loading()}
{:else if loadError} -
A
Atlas não conseguiu abrir{loadError}
+
A
{m.shell_load_failed_title()}{loadError}
{:else if snapshot}
-