feat(fleet): foto da embarcação + horímetro + cadastro + matrícula v1.4.1
Some checks are pending
Build Android (APK + AAB) / build-android (push) Waiting to run

Novos campos por embarcação:
- Foto (capture câmera ou galeria, resize automático max 1280px JPEG q=0.85)
- Horímetro inicial do motor
- Data de cadastro (defaulta hoje em novas)
- Matrícula / TIE (Capitania)
- Notas livres

UI:
- Preview circular no editor com botões câmera/galeria/remover
- Avatar circular no header (foto se houver, ícone do tipo senão)
- Avatar 44x44 na lista da frota
- Foto guardada no IndexedDB (mesma store das mídias de viagem/manutenção)
- Lifecycle pareado: remover barco apaga foto, trocar foto apaga antiga
- /apk redirect aponta pra v1.4.1

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PontualTech / Karlão 2026-04-28 06:37:10 -03:00
parent 21af0f00b7
commit 5833efcc48
5 changed files with 454 additions and 18 deletions

View file

@ -528,6 +528,34 @@ header{
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)} .fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)} .fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Boat Photo === */
.boat-photo-row{display:flex;gap:14px;align-items:flex-start}
.boat-photo-preview{
width:96px;height:96px;flex-shrink:0;
background:var(--bg-canvas);border:1px solid var(--rule);
display:flex;align-items:center;justify-content:center;
overflow:hidden;position:relative;
}
.boat-photo-preview img{width:100%;height:100%;object-fit:cover;display:block}
.boat-photo-placeholder{font-size:36px;opacity:.4}
.boat-photo-actions{flex:1;display:flex;flex-direction:column;justify-content:center}
/* Avatares pequenos (lista frota + header) */
.fleet-avatar{
width:44px;height:44px;flex-shrink:0;
background:var(--bg-aged);border:1px solid var(--rule);
display:flex;align-items:center;justify-content:center;
overflow:hidden;font-size:22px;
}
.fleet-avatar img{width:100%;height:100%;object-fit:cover}
.boat-header-avatar{
width:34px;height:34px;border-radius:50%;flex-shrink:0;
background:rgba(250,242,221,.12);border:1.5px solid rgba(250,242,221,.3);
overflow:hidden;display:flex;align-items:center;justify-content:center;
font-size:16px;color:rgba(250,242,221,.6);
}
.boat-header-avatar img{width:100%;height:100%;object-fit:cover}
/* === Anchor Calculator === */ /* === Anchor Calculator === */
.anchor-calc{ .anchor-calc{
background:var(--bg-canvas);border:1px solid var(--rule); background:var(--bg-canvas);border:1px solid var(--rule);
@ -1338,6 +1366,7 @@ header{
</g> </g>
<text x="50" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="6" fill="currentColor" font-weight="600">N</text> <text x="50" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="6" fill="currentColor" font-weight="600">N</text>
</svg> </svg>
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota"></div>
<div class="boat-info"> <div class="boat-info">
<div class="boat-tagline">Diário de Bordo · Logbook</div> <div class="boat-tagline">Diário de Bordo · Logbook</div>
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota"> <button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
@ -1879,6 +1908,26 @@ Hora: {HORA}</textarea>
<div class="modal-body"> <div class="modal-body">
<form id="boat-editor-form" onsubmit="saveBoatFromForm(event)"> <form id="boat-editor-form" onsubmit="saveBoatFromForm(event)">
<input type="hidden" id="boat-edit-id"> <input type="hidden" id="boat-edit-id">
<input type="hidden" id="boat-edit-photo-id">
<div class="field">
<label class="field-label">Foto da embarcação</label>
<div class="boat-photo-row">
<div class="boat-photo-preview" id="boat-photo-preview">
<span class="boat-photo-placeholder"></span>
</div>
<div class="boat-photo-actions">
<label class="btn btn-sm btn-block" style="cursor:pointer;display:block">
📷 Tirar foto
<input type="file" accept="image/*" capture="environment" onchange="handleBoatPhoto(event)" style="display:none">
</label>
<label class="btn btn-sm btn-block" style="cursor:pointer;display:block;margin-top:6px">
🖼 Da galeria
<input type="file" accept="image/*" onchange="handleBoatPhoto(event)" style="display:none">
</label>
<button type="button" class="btn btn-sm btn-danger btn-block" onclick="clearBoatPhoto()" id="boat-photo-clear" style="margin-top:6px;display:none">Remover foto</button>
</div>
</div>
</div>
<div class="field"><label class="field-label">Nome</label> <div class="field"><label class="field-label">Nome</label>
<input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta"> <input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta">
</div> </div>
@ -1910,8 +1959,25 @@ Hora: {HORA}</textarea>
<input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível"> <input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível">
</div> </div>
</div> </div>
<div class="field"><label class="field-label">Ano (opcional)</label> <div class="field-row">
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018"> <div class="field"><label class="field-label">Ano (opcional)</label>
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018">
</div>
<div class="field"><label class="field-label">Data de cadastro</label>
<input type="date" id="boat-edit-registered-at">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Horímetro inicial</label>
<input type="number" id="boat-edit-engine-hours-initial" step="0.1" min="0" placeholder="ex: 1240.5">
<div class="field-hint" style="margin-top:4px">Horas do motor no dia que começou a registrar.</div>
</div>
<div class="field"><label class="field-label">Matrícula / TIE</label>
<input type="text" id="boat-edit-registration" maxlength="32" placeholder="ex: SP-2348-CT">
</div>
</div>
<div class="field"><label class="field-label">Notas (opcional)</label>
<textarea id="boat-edit-notes" maxlength="500" rows="2" placeholder="Marina, observações, manutenção pendente..."></textarea>
</div> </div>
<div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div> <div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div>
</form> </form>
@ -1965,11 +2031,24 @@ function migrateBoatsSchema(){
length:null,beam:null,draft:null, length:null,beam:null,draft:null,
chainTotal:null, chainTotal:null,
year:null, year:null,
photoId:null,
engineHoursInitial:null,
registeredAt:null,
registrationNumber:'',
notes:'',
createdAt:Date.now(), createdAt:Date.now(),
}; };
state.boats.push(b); state.boats.push(b);
state.activeBoatId=b.id; state.activeBoatId=b.id;
} }
// Garante campos novos em barcos existentes (forward-compat)
state.boats.forEach(b=>{
if(!('photoId' in b))b.photoId=null;
if(!('engineHoursInitial' in b))b.engineHoursInitial=null;
if(!('registeredAt' in b))b.registeredAt=null;
if(!('registrationNumber' in b))b.registrationNumber='';
if(!('notes' in b))b.notes='';
});
if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id; if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id;
if(!state.units)state.units='metric'; if(!state.units)state.units='metric';
} }
@ -1996,6 +2075,11 @@ function addBoat(data){
draft:lenFromInput(data.draft), draft:lenFromInput(data.draft),
chainTotal:lenFromInput(data.chainTotal), chainTotal:lenFromInput(data.chainTotal),
year:data.year?parseInt(data.year):null, year:data.year?parseInt(data.year):null,
photoId:data.photoId||null,
engineHoursInitial:data.engineHoursInitial?parseFloat(data.engineHoursInitial):null,
registeredAt:data.registeredAt||null,
registrationNumber:data.registrationNumber||'',
notes:data.notes||'',
createdAt:Date.now(), createdAt:Date.now(),
}; };
state.boats.push(b); state.boats.push(b);
@ -2015,11 +2099,19 @@ function updateBoat(id,data){
if('draft' in data)b.draft=lenFromInput(data.draft); if('draft' in data)b.draft=lenFromInput(data.draft);
if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal); if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal);
if('year' in data)b.year=data.year?parseInt(data.year):null; if('year' in data)b.year=data.year?parseInt(data.year):null;
if('photoId' in data)b.photoId=data.photoId;
if('engineHoursInitial' in data)b.engineHoursInitial=data.engineHoursInitial?parseFloat(data.engineHoursInitial):null;
if('registeredAt' in data)b.registeredAt=data.registeredAt||null;
if('registrationNumber' in data)b.registrationNumber=data.registrationNumber||'';
if('notes' in data)b.notes=data.notes||'';
saveState(); saveState();
} }
function removeBoat(id){ function removeBoat(id){
if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return} if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return}
const b=state.boats.find(x=>x.id===id);
// Limpa foto do IndexedDB se houver
if(b?.photoId){dbDelete(b.photoId).catch(()=>{})}
state.boats=state.boats.filter(b=>b.id!==id); state.boats=state.boats.filter(b=>b.id!==id);
if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null; if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null;
saveState(); saveState();
@ -2243,16 +2335,33 @@ function renderGPSBanner(){
function bindHeader(){ function bindHeader(){
const nameEl=document.getElementById('boat-name-display'); const nameEl=document.getElementById('boat-name-display');
const metaEl=document.getElementById('boat-model-display'); const metaEl=document.getElementById('boat-model-display');
const avatarEl=document.getElementById('boat-header-avatar');
if(!nameEl||!metaEl)return; if(!nameEl||!metaEl)return;
const b=activeBoat(); const b=activeBoat();
if(!b){nameEl.textContent='Sem embarcação';metaEl.textContent='Toque para adicionar';return} if(!b){
nameEl.textContent='Sem embarcação';
metaEl.textContent='Toque para adicionar';
if(avatarEl)avatarEl.innerHTML='⛵';
return;
}
nameEl.textContent=b.name||'Embarcação'; nameEl.textContent=b.name||'Embarcação';
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing; const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const parts=[`${t.icon} ${t.label}`]; const parts=[`${t.icon} ${t.label}`];
if(b.model)parts.push(b.model); if(b.model)parts.push(b.model);
if(b.length)parts.push(fmtLen(b.length,1)); if(b.length)parts.push(fmtLen(b.length,1));
metaEl.textContent=parts.join(' · '); metaEl.textContent=parts.join(' · ');
// sync legacy state.boat para compat com qualquer código antigo que ainda use // Avatar: foto se houver, senão ícone do tipo
if(avatarEl){
avatarEl.innerHTML=t.icon;
if(b.photoId){
dbGet(b.photoId).then(item=>{
if(item&&activeBoat()?.id===b.id){
avatarEl.innerHTML=`<img src="${getMediaUrl(item)}" alt="">`;
}
}).catch(()=>{});
}
}
// sync legacy state.boat para compat
state.boat={name:b.name,model:b.model}; state.boat={name:b.name,model:b.model};
} }
@ -2277,7 +2386,7 @@ function renderFleetList(){
if(b.model)meta.push(b.model); if(b.model)meta.push(b.model);
if(b.length)meta.push(fmtLen(b.length,1)); if(b.length)meta.push(fmtLen(b.length,1));
return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')"> return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')">
<div class="fleet-icon">${t.icon}</div> <div class="fleet-avatar" data-boat-id="${b.id}">${t.icon}</div>
<div class="fleet-info"> <div class="fleet-info">
<div class="fleet-name">${escapeHtml(b.name)}</div> <div class="fleet-name">${escapeHtml(b.name)}</div>
<div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div> <div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div>
@ -2286,6 +2395,20 @@ function renderFleetList(){
<button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar"></button> <button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar"></button>
</div>`; </div>`;
}).join(''); }).join('');
// Carrega fotos async — não bloqueia a render
state.boats.forEach(b=>{if(b.photoId)loadBoatAvatarInto(`.fleet-avatar[data-boat-id="${b.id}"]`,b.photoId)});
}
async function loadBoatAvatarInto(selector,photoId){
if(!photoId)return;
try{
const item=await dbGet(photoId);
if(!item)return;
const url=getMediaUrl(item);
document.querySelectorAll(selector).forEach(el=>{
el.innerHTML=`<img src="${url}" alt="">`;
});
}catch(e){}
} }
function setActiveBoatAndClose(id){ function setActiveBoatAndClose(id){
@ -2311,6 +2434,13 @@ function openBoatEditor(id){
const chainEl=document.getElementById('boat-edit-chain'); const chainEl=document.getElementById('boat-edit-chain');
chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0); chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0);
document.getElementById('boat-edit-year').value=b.year||''; document.getElementById('boat-edit-year').value=b.year||'';
// Novos campos
document.getElementById('boat-edit-engine-hours-initial').value=b.engineHoursInitial||'';
document.getElementById('boat-edit-registered-at').value=b.registeredAt||(isNew?new Date().toISOString().slice(0,10):'');
document.getElementById('boat-edit-registration').value=b.registrationNumber||'';
document.getElementById('boat-edit-notes').value=b.notes||'';
document.getElementById('boat-edit-photo-id').value=b.photoId||'';
renderBoatPhotoPreview(b.photoId);
// Atualizar labels de unidade // Atualizar labels de unidade
const unitLabel=`(${lengthUnit()})`; const unitLabel=`(${lengthUnit()})`;
['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{ ['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{
@ -2323,9 +2453,81 @@ function openBoatEditor(id){
setTimeout(()=>document.getElementById('boat-edit-name').focus(),100); setTimeout(()=>document.getElementById('boat-edit-name').focus(),100);
} }
function saveBoatFromForm(ev){ async function renderBoatPhotoPreview(photoId){
const wrap=document.getElementById('boat-photo-preview');
const clearBtn=document.getElementById('boat-photo-clear');
if(!wrap)return;
if(!photoId){
const t=BOAT_TYPES[document.getElementById('boat-edit-type').value]||BOAT_TYPES.sailing;
wrap.innerHTML=`<span class="boat-photo-placeholder">${t.icon}</span>`;
if(clearBtn)clearBtn.style.display='none';
return;
}
try{
const item=await dbGet(photoId);
if(!item){wrap.innerHTML='<span class="boat-photo-placeholder"></span>';return}
const url=getMediaUrl(item);
wrap.innerHTML=`<img src="${url}" alt="foto da embarcação">`;
if(clearBtn)clearBtn.style.display='block';
}catch(e){
wrap.innerHTML='<span class="boat-photo-placeholder"></span>';
}
}
async function handleBoatPhoto(ev){
const file=ev.target.files?.[0];
ev.target.value='';
if(!file)return;
if(!file.type.startsWith('image/')){toast('Selecione uma imagem');return}
// Comprime/redimensiona pra max 1280px (economia de storage e sync)
try{
const blob=await resizeImage(file,1280,0.85);
const oldId=document.getElementById('boat-edit-photo-id').value;
if(oldId){await dbDelete(oldId).catch(()=>{})}
const newId='boat-photo-'+uid();
await dbPut({id:newId,kind:'photo',blob,mime:blob.type||'image/jpeg',parentId:document.getElementById('boat-edit-id').value||'pending',parentType:'boat',createdAt:Date.now()});
document.getElementById('boat-edit-photo-id').value=newId;
await renderBoatPhotoPreview(newId);
toast('Foto carregada');
}catch(e){
console.warn('photo upload failed',e);
toast('Erro ao carregar foto');
}
}
async function clearBoatPhoto(){
const oldId=document.getElementById('boat-edit-photo-id').value;
if(oldId){await dbDelete(oldId).catch(()=>{})}
document.getElementById('boat-edit-photo-id').value='';
renderBoatPhotoPreview(null);
}
// Resize/compress image via canvas (mantém proporção, max maxDim, jpeg quality)
function resizeImage(file,maxDim=1280,quality=0.85){
return new Promise((resolve,reject)=>{
const img=new Image();
img.onload=()=>{
try{
let{width,height}=img;
if(width>height){if(width>maxDim){height=Math.round(height*maxDim/width);width=maxDim}}
else{if(height>maxDim){width=Math.round(width*maxDim/height);height=maxDim}}
const canvas=document.createElement('canvas');
canvas.width=width;canvas.height=height;
const ctx=canvas.getContext('2d');
ctx.drawImage(img,0,0,width,height);
canvas.toBlob(b=>b?resolve(b):reject(new Error('toBlob null')),'image/jpeg',quality);
}catch(e){reject(e)}
finally{URL.revokeObjectURL(img.src)}
};
img.onerror=()=>{URL.revokeObjectURL(img.src);reject(new Error('image load error'))};
img.src=URL.createObjectURL(file);
});
}
async function saveBoatFromForm(ev){
ev.preventDefault(); ev.preventDefault();
const id=document.getElementById('boat-edit-id').value; const id=document.getElementById('boat-edit-id').value;
const photoId=document.getElementById('boat-edit-photo-id').value||null;
const data={ const data={
name:document.getElementById('boat-edit-name').value.trim(), name:document.getElementById('boat-edit-name').value.trim(),
type:document.getElementById('boat-edit-type').value, type:document.getElementById('boat-edit-type').value,
@ -2335,10 +2537,26 @@ function saveBoatFromForm(ev){
draft:document.getElementById('boat-edit-draft').value, draft:document.getElementById('boat-edit-draft').value,
chainTotal:document.getElementById('boat-edit-chain').value, chainTotal:document.getElementById('boat-edit-chain').value,
year:document.getElementById('boat-edit-year').value, year:document.getElementById('boat-edit-year').value,
photoId,
engineHoursInitial:document.getElementById('boat-edit-engine-hours-initial').value,
registeredAt:document.getElementById('boat-edit-registered-at').value,
registrationNumber:document.getElementById('boat-edit-registration').value.trim(),
notes:document.getElementById('boat-edit-notes').value.trim(),
}; };
if(!data.name){toast('Informe o nome da embarcação');return} if(!data.name){toast('Informe o nome da embarcação');return}
let boatId=id;
if(id){updateBoat(id,data);toast('Embarcação atualizada')} if(id){updateBoat(id,data);toast('Embarcação atualizada')}
else{const b=addBoat(data);state.activeBoatId=b.id;saveState();toast('Embarcação adicionada')} else{const b=addBoat(data);state.activeBoatId=b.id;boatId=b.id;saveState();toast('Embarcação adicionada')}
// Re-vincular parentId da foto (nova embarcação tinha 'pending' como parentId)
if(photoId){
try{
const item=await dbGet(photoId);
if(item&&item.parentId!==boatId){
item.parentId=boatId;
await dbPut(item);
}
}catch(e){}
}
closeModal('boat-editor-modal'); closeModal('boat-editor-modal');
bindHeader(); bindHeader();
renderAll(); renderAll();

View file

@ -7,8 +7,8 @@ android {
applicationId "br.com.pontualtech.shivao" applicationId "br.com.pontualtech.shivao"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 4 versionCode 5
versionName "1.4.0" versionName "1.4.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View file

@ -1,6 +1,6 @@
{ {
"name": "shivao-mobile", "name": "shivao-mobile",
"version": "1.4.0", "version": "1.4.1",
"description": "Shivao app nativo (Capacitor wrapper Android/iOS)", "description": "Shivao app nativo (Capacitor wrapper Android/iOS)",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View file

@ -528,6 +528,34 @@ header{
.fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)} .fleet-units-toggle button.active{background:var(--brass);color:var(--bg-paper)}
.fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)} .fleet-units-toggle button:hover:not(.active){background:var(--bg-aged)}
/* === Boat Photo === */
.boat-photo-row{display:flex;gap:14px;align-items:flex-start}
.boat-photo-preview{
width:96px;height:96px;flex-shrink:0;
background:var(--bg-canvas);border:1px solid var(--rule);
display:flex;align-items:center;justify-content:center;
overflow:hidden;position:relative;
}
.boat-photo-preview img{width:100%;height:100%;object-fit:cover;display:block}
.boat-photo-placeholder{font-size:36px;opacity:.4}
.boat-photo-actions{flex:1;display:flex;flex-direction:column;justify-content:center}
/* Avatares pequenos (lista frota + header) */
.fleet-avatar{
width:44px;height:44px;flex-shrink:0;
background:var(--bg-aged);border:1px solid var(--rule);
display:flex;align-items:center;justify-content:center;
overflow:hidden;font-size:22px;
}
.fleet-avatar img{width:100%;height:100%;object-fit:cover}
.boat-header-avatar{
width:34px;height:34px;border-radius:50%;flex-shrink:0;
background:rgba(250,242,221,.12);border:1.5px solid rgba(250,242,221,.3);
overflow:hidden;display:flex;align-items:center;justify-content:center;
font-size:16px;color:rgba(250,242,221,.6);
}
.boat-header-avatar img{width:100%;height:100%;object-fit:cover}
/* === Anchor Calculator === */ /* === Anchor Calculator === */
.anchor-calc{ .anchor-calc{
background:var(--bg-canvas);border:1px solid var(--rule); background:var(--bg-canvas);border:1px solid var(--rule);
@ -1338,6 +1366,7 @@ header{
</g> </g>
<text x="50" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="6" fill="currentColor" font-weight="600">N</text> <text x="50" y="14" text-anchor="middle" font-family="JetBrains Mono,monospace" font-size="6" fill="currentColor" font-weight="600">N</text>
</svg> </svg>
<div class="boat-header-avatar" id="boat-header-avatar" onclick="openFleetManager()" style="cursor:pointer" title="Gerenciar frota"></div>
<div class="boat-info"> <div class="boat-info">
<div class="boat-tagline">Diário de Bordo · Logbook</div> <div class="boat-tagline">Diário de Bordo · Logbook</div>
<button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota"> <button type="button" class="boat-name boat-selector" id="boat-selector" onclick="openFleetManager()" title="Gerenciar frota">
@ -1879,6 +1908,26 @@ Hora: {HORA}</textarea>
<div class="modal-body"> <div class="modal-body">
<form id="boat-editor-form" onsubmit="saveBoatFromForm(event)"> <form id="boat-editor-form" onsubmit="saveBoatFromForm(event)">
<input type="hidden" id="boat-edit-id"> <input type="hidden" id="boat-edit-id">
<input type="hidden" id="boat-edit-photo-id">
<div class="field">
<label class="field-label">Foto da embarcação</label>
<div class="boat-photo-row">
<div class="boat-photo-preview" id="boat-photo-preview">
<span class="boat-photo-placeholder"></span>
</div>
<div class="boat-photo-actions">
<label class="btn btn-sm btn-block" style="cursor:pointer;display:block">
📷 Tirar foto
<input type="file" accept="image/*" capture="environment" onchange="handleBoatPhoto(event)" style="display:none">
</label>
<label class="btn btn-sm btn-block" style="cursor:pointer;display:block;margin-top:6px">
🖼 Da galeria
<input type="file" accept="image/*" onchange="handleBoatPhoto(event)" style="display:none">
</label>
<button type="button" class="btn btn-sm btn-danger btn-block" onclick="clearBoatPhoto()" id="boat-photo-clear" style="margin-top:6px;display:none">Remover foto</button>
</div>
</div>
</div>
<div class="field"><label class="field-label">Nome</label> <div class="field"><label class="field-label">Nome</label>
<input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta"> <input type="text" id="boat-edit-name" required maxlength="40" placeholder="ex: Shivao, Maré Alta">
</div> </div>
@ -1910,8 +1959,25 @@ Hora: {HORA}</textarea>
<input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível"> <input type="number" id="boat-edit-chain" step="1" min="0" placeholder="total disponível">
</div> </div>
</div> </div>
<div class="field"><label class="field-label">Ano (opcional)</label> <div class="field-row">
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018"> <div class="field"><label class="field-label">Ano (opcional)</label>
<input type="number" id="boat-edit-year" min="1900" max="2100" placeholder="ex: 2018">
</div>
<div class="field"><label class="field-label">Data de cadastro</label>
<input type="date" id="boat-edit-registered-at">
</div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Horímetro inicial</label>
<input type="number" id="boat-edit-engine-hours-initial" step="0.1" min="0" placeholder="ex: 1240.5">
<div class="field-hint" style="margin-top:4px">Horas do motor no dia que começou a registrar.</div>
</div>
<div class="field"><label class="field-label">Matrícula / TIE</label>
<input type="text" id="boat-edit-registration" maxlength="32" placeholder="ex: SP-2348-CT">
</div>
</div>
<div class="field"><label class="field-label">Notas (opcional)</label>
<textarea id="boat-edit-notes" maxlength="500" rows="2" placeholder="Marina, observações, manutenção pendente..."></textarea>
</div> </div>
<div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div> <div class="field-hint">⚓ Comprimento + calado são usados pra calcular raio de giro no fundeio.</div>
</form> </form>
@ -1965,11 +2031,24 @@ function migrateBoatsSchema(){
length:null,beam:null,draft:null, length:null,beam:null,draft:null,
chainTotal:null, chainTotal:null,
year:null, year:null,
photoId:null,
engineHoursInitial:null,
registeredAt:null,
registrationNumber:'',
notes:'',
createdAt:Date.now(), createdAt:Date.now(),
}; };
state.boats.push(b); state.boats.push(b);
state.activeBoatId=b.id; state.activeBoatId=b.id;
} }
// Garante campos novos em barcos existentes (forward-compat)
state.boats.forEach(b=>{
if(!('photoId' in b))b.photoId=null;
if(!('engineHoursInitial' in b))b.engineHoursInitial=null;
if(!('registeredAt' in b))b.registeredAt=null;
if(!('registrationNumber' in b))b.registrationNumber='';
if(!('notes' in b))b.notes='';
});
if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id; if(!state.activeBoatId&&state.boats[0])state.activeBoatId=state.boats[0].id;
if(!state.units)state.units='metric'; if(!state.units)state.units='metric';
} }
@ -1996,6 +2075,11 @@ function addBoat(data){
draft:lenFromInput(data.draft), draft:lenFromInput(data.draft),
chainTotal:lenFromInput(data.chainTotal), chainTotal:lenFromInput(data.chainTotal),
year:data.year?parseInt(data.year):null, year:data.year?parseInt(data.year):null,
photoId:data.photoId||null,
engineHoursInitial:data.engineHoursInitial?parseFloat(data.engineHoursInitial):null,
registeredAt:data.registeredAt||null,
registrationNumber:data.registrationNumber||'',
notes:data.notes||'',
createdAt:Date.now(), createdAt:Date.now(),
}; };
state.boats.push(b); state.boats.push(b);
@ -2015,11 +2099,19 @@ function updateBoat(id,data){
if('draft' in data)b.draft=lenFromInput(data.draft); if('draft' in data)b.draft=lenFromInput(data.draft);
if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal); if('chainTotal' in data)b.chainTotal=lenFromInput(data.chainTotal);
if('year' in data)b.year=data.year?parseInt(data.year):null; if('year' in data)b.year=data.year?parseInt(data.year):null;
if('photoId' in data)b.photoId=data.photoId;
if('engineHoursInitial' in data)b.engineHoursInitial=data.engineHoursInitial?parseFloat(data.engineHoursInitial):null;
if('registeredAt' in data)b.registeredAt=data.registeredAt||null;
if('registrationNumber' in data)b.registrationNumber=data.registrationNumber||'';
if('notes' in data)b.notes=data.notes||'';
saveState(); saveState();
} }
function removeBoat(id){ function removeBoat(id){
if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return} if(state.boats.length<=1){toast('Mantenha pelo menos 1 embarcação');return}
const b=state.boats.find(x=>x.id===id);
// Limpa foto do IndexedDB se houver
if(b?.photoId){dbDelete(b.photoId).catch(()=>{})}
state.boats=state.boats.filter(b=>b.id!==id); state.boats=state.boats.filter(b=>b.id!==id);
if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null; if(state.activeBoatId===id)state.activeBoatId=state.boats[0]?.id||null;
saveState(); saveState();
@ -2243,16 +2335,33 @@ function renderGPSBanner(){
function bindHeader(){ function bindHeader(){
const nameEl=document.getElementById('boat-name-display'); const nameEl=document.getElementById('boat-name-display');
const metaEl=document.getElementById('boat-model-display'); const metaEl=document.getElementById('boat-model-display');
const avatarEl=document.getElementById('boat-header-avatar');
if(!nameEl||!metaEl)return; if(!nameEl||!metaEl)return;
const b=activeBoat(); const b=activeBoat();
if(!b){nameEl.textContent='Sem embarcação';metaEl.textContent='Toque para adicionar';return} if(!b){
nameEl.textContent='Sem embarcação';
metaEl.textContent='Toque para adicionar';
if(avatarEl)avatarEl.innerHTML='⛵';
return;
}
nameEl.textContent=b.name||'Embarcação'; nameEl.textContent=b.name||'Embarcação';
const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing; const t=BOAT_TYPES[b.type]||BOAT_TYPES.sailing;
const parts=[`${t.icon} ${t.label}`]; const parts=[`${t.icon} ${t.label}`];
if(b.model)parts.push(b.model); if(b.model)parts.push(b.model);
if(b.length)parts.push(fmtLen(b.length,1)); if(b.length)parts.push(fmtLen(b.length,1));
metaEl.textContent=parts.join(' · '); metaEl.textContent=parts.join(' · ');
// sync legacy state.boat para compat com qualquer código antigo que ainda use // Avatar: foto se houver, senão ícone do tipo
if(avatarEl){
avatarEl.innerHTML=t.icon;
if(b.photoId){
dbGet(b.photoId).then(item=>{
if(item&&activeBoat()?.id===b.id){
avatarEl.innerHTML=`<img src="${getMediaUrl(item)}" alt="">`;
}
}).catch(()=>{});
}
}
// sync legacy state.boat para compat
state.boat={name:b.name,model:b.model}; state.boat={name:b.name,model:b.model};
} }
@ -2277,7 +2386,7 @@ function renderFleetList(){
if(b.model)meta.push(b.model); if(b.model)meta.push(b.model);
if(b.length)meta.push(fmtLen(b.length,1)); if(b.length)meta.push(fmtLen(b.length,1));
return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')"> return `<div class="fleet-item ${isActive?'active':''}" onclick="setActiveBoatAndClose('${b.id}')">
<div class="fleet-icon">${t.icon}</div> <div class="fleet-avatar" data-boat-id="${b.id}">${t.icon}</div>
<div class="fleet-info"> <div class="fleet-info">
<div class="fleet-name">${escapeHtml(b.name)}</div> <div class="fleet-name">${escapeHtml(b.name)}</div>
<div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div> <div class="fleet-meta">${meta.map(escapeHtml).join(' · ')}</div>
@ -2286,6 +2395,20 @@ function renderFleetList(){
<button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar"></button> <button type="button" class="fleet-edit-icon" onclick="event.stopPropagation();openBoatEditor('${b.id}')" title="Editar"></button>
</div>`; </div>`;
}).join(''); }).join('');
// Carrega fotos async — não bloqueia a render
state.boats.forEach(b=>{if(b.photoId)loadBoatAvatarInto(`.fleet-avatar[data-boat-id="${b.id}"]`,b.photoId)});
}
async function loadBoatAvatarInto(selector,photoId){
if(!photoId)return;
try{
const item=await dbGet(photoId);
if(!item)return;
const url=getMediaUrl(item);
document.querySelectorAll(selector).forEach(el=>{
el.innerHTML=`<img src="${url}" alt="">`;
});
}catch(e){}
} }
function setActiveBoatAndClose(id){ function setActiveBoatAndClose(id){
@ -2311,6 +2434,13 @@ function openBoatEditor(id){
const chainEl=document.getElementById('boat-edit-chain'); const chainEl=document.getElementById('boat-edit-chain');
chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0); chainEl.value=b.chainTotal==null?'':lenToDisplay(b.chainTotal).toFixed(0);
document.getElementById('boat-edit-year').value=b.year||''; document.getElementById('boat-edit-year').value=b.year||'';
// Novos campos
document.getElementById('boat-edit-engine-hours-initial').value=b.engineHoursInitial||'';
document.getElementById('boat-edit-registered-at').value=b.registeredAt||(isNew?new Date().toISOString().slice(0,10):'');
document.getElementById('boat-edit-registration').value=b.registrationNumber||'';
document.getElementById('boat-edit-notes').value=b.notes||'';
document.getElementById('boat-edit-photo-id').value=b.photoId||'';
renderBoatPhotoPreview(b.photoId);
// Atualizar labels de unidade // Atualizar labels de unidade
const unitLabel=`(${lengthUnit()})`; const unitLabel=`(${lengthUnit()})`;
['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{ ['boat-len-unit','boat-beam-unit','boat-draft-unit','boat-chain-unit'].forEach(id=>{
@ -2323,9 +2453,81 @@ function openBoatEditor(id){
setTimeout(()=>document.getElementById('boat-edit-name').focus(),100); setTimeout(()=>document.getElementById('boat-edit-name').focus(),100);
} }
function saveBoatFromForm(ev){ async function renderBoatPhotoPreview(photoId){
const wrap=document.getElementById('boat-photo-preview');
const clearBtn=document.getElementById('boat-photo-clear');
if(!wrap)return;
if(!photoId){
const t=BOAT_TYPES[document.getElementById('boat-edit-type').value]||BOAT_TYPES.sailing;
wrap.innerHTML=`<span class="boat-photo-placeholder">${t.icon}</span>`;
if(clearBtn)clearBtn.style.display='none';
return;
}
try{
const item=await dbGet(photoId);
if(!item){wrap.innerHTML='<span class="boat-photo-placeholder"></span>';return}
const url=getMediaUrl(item);
wrap.innerHTML=`<img src="${url}" alt="foto da embarcação">`;
if(clearBtn)clearBtn.style.display='block';
}catch(e){
wrap.innerHTML='<span class="boat-photo-placeholder"></span>';
}
}
async function handleBoatPhoto(ev){
const file=ev.target.files?.[0];
ev.target.value='';
if(!file)return;
if(!file.type.startsWith('image/')){toast('Selecione uma imagem');return}
// Comprime/redimensiona pra max 1280px (economia de storage e sync)
try{
const blob=await resizeImage(file,1280,0.85);
const oldId=document.getElementById('boat-edit-photo-id').value;
if(oldId){await dbDelete(oldId).catch(()=>{})}
const newId='boat-photo-'+uid();
await dbPut({id:newId,kind:'photo',blob,mime:blob.type||'image/jpeg',parentId:document.getElementById('boat-edit-id').value||'pending',parentType:'boat',createdAt:Date.now()});
document.getElementById('boat-edit-photo-id').value=newId;
await renderBoatPhotoPreview(newId);
toast('Foto carregada');
}catch(e){
console.warn('photo upload failed',e);
toast('Erro ao carregar foto');
}
}
async function clearBoatPhoto(){
const oldId=document.getElementById('boat-edit-photo-id').value;
if(oldId){await dbDelete(oldId).catch(()=>{})}
document.getElementById('boat-edit-photo-id').value='';
renderBoatPhotoPreview(null);
}
// Resize/compress image via canvas (mantém proporção, max maxDim, jpeg quality)
function resizeImage(file,maxDim=1280,quality=0.85){
return new Promise((resolve,reject)=>{
const img=new Image();
img.onload=()=>{
try{
let{width,height}=img;
if(width>height){if(width>maxDim){height=Math.round(height*maxDim/width);width=maxDim}}
else{if(height>maxDim){width=Math.round(width*maxDim/height);height=maxDim}}
const canvas=document.createElement('canvas');
canvas.width=width;canvas.height=height;
const ctx=canvas.getContext('2d');
ctx.drawImage(img,0,0,width,height);
canvas.toBlob(b=>b?resolve(b):reject(new Error('toBlob null')),'image/jpeg',quality);
}catch(e){reject(e)}
finally{URL.revokeObjectURL(img.src)}
};
img.onerror=()=>{URL.revokeObjectURL(img.src);reject(new Error('image load error'))};
img.src=URL.createObjectURL(file);
});
}
async function saveBoatFromForm(ev){
ev.preventDefault(); ev.preventDefault();
const id=document.getElementById('boat-edit-id').value; const id=document.getElementById('boat-edit-id').value;
const photoId=document.getElementById('boat-edit-photo-id').value||null;
const data={ const data={
name:document.getElementById('boat-edit-name').value.trim(), name:document.getElementById('boat-edit-name').value.trim(),
type:document.getElementById('boat-edit-type').value, type:document.getElementById('boat-edit-type').value,
@ -2335,10 +2537,26 @@ function saveBoatFromForm(ev){
draft:document.getElementById('boat-edit-draft').value, draft:document.getElementById('boat-edit-draft').value,
chainTotal:document.getElementById('boat-edit-chain').value, chainTotal:document.getElementById('boat-edit-chain').value,
year:document.getElementById('boat-edit-year').value, year:document.getElementById('boat-edit-year').value,
photoId,
engineHoursInitial:document.getElementById('boat-edit-engine-hours-initial').value,
registeredAt:document.getElementById('boat-edit-registered-at').value,
registrationNumber:document.getElementById('boat-edit-registration').value.trim(),
notes:document.getElementById('boat-edit-notes').value.trim(),
}; };
if(!data.name){toast('Informe o nome da embarcação');return} if(!data.name){toast('Informe o nome da embarcação');return}
let boatId=id;
if(id){updateBoat(id,data);toast('Embarcação atualizada')} if(id){updateBoat(id,data);toast('Embarcação atualizada')}
else{const b=addBoat(data);state.activeBoatId=b.id;saveState();toast('Embarcação adicionada')} else{const b=addBoat(data);state.activeBoatId=b.id;boatId=b.id;saveState();toast('Embarcação adicionada')}
// Re-vincular parentId da foto (nova embarcação tinha 'pending' como parentId)
if(photoId){
try{
const item=await dbGet(photoId);
if(item&&item.parentId!==boatId){
item.parentId=boatId;
await dbPut(item);
}
}catch(e){}
}
closeModal('boat-editor-modal'); closeModal('boat-editor-modal');
bindHeader(); bindHeader();
renderAll(); renderAll();

View file

@ -264,7 +264,7 @@ app.get('/.well-known/assetlinks.json', (req, res) => {
}); });
// Atalho: /apk redireciona pra última APK release no Forgejo // Atalho: /apk redireciona pra última APK release no Forgejo
const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.4.0/Shivao-v1.4.0.apk'; const LATEST_APK_URL = 'https://git.pontualtech.work/karlao/shivao-projeto/releases/download/v1.4.1/Shivao-v1.4.1.apk';
app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL)); app.get('/apk', (req, res) => res.redirect(302, LATEST_APK_URL));
// Página A4 imprimível com QR Code + instruções (cola no barco/marina) // Página A4 imprimível com QR Code + instruções (cola no barco/marina)