feat(android): a selection bar in the list, and Duplicate everywhere

The floating menu was wrong for a list. It closed on the next tap, so opening
an entry afterwards took two gestures and felt like a button had got stuck.

Android's contextual bar instead: a long press selects, the top bar takes the
place of search, says how many rows are held, and offers duplicate and
delete. It goes away when nothing is held. One does not search while choosing
what to delete, and two rows of controls would fight over the same thumb.

Selection is multiple, because the cost is in the bar, not in the work: the
patch closure loops, and ten deletions become ONE publish. The container goes
up whole on every write - deleting ten entries one by one would pay that trip
ten times, and give nine chances to fail halfway.

A tap during selection picks rather than opens, which is also the way out:
unpicking the last row closes the bar. The hardware back cancels the
selection before locking the vault - locking because someone wanted to cancel
a choice is the opposite of what the gesture means.

Both menus stay, with distinct jobs: the bar acts on rows chosen in the list,
the detail's three dots act on the entry in front of you. Duplicate copies
everything, attachments included, as the desktop does - a copy that silently
lost the attached document would be a surprise discovered too late.
This commit is contained in:
r-zakarya
2026-08-22 00:10:38 +01:00
parent 0619740da1
commit ebd4752ec9
3 changed files with 261 additions and 61 deletions
+60
View File
@@ -65,6 +65,14 @@ function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
/// une suppression qui s'y arreterait ne se propagerait donc nulle part.
function DeleteEntry(const APlainJson, AUuid: string): string;
/// Duplique une entree : meme contenu, uuid neuf, nom suffixe.
///
/// COPIE INTEGRALE, pieces jointes comprises. Le poste fait de meme, et une
/// copie qui perdrait le document attache serait une surprise le jour ou l'on
/// s'en apercevrait - c'est-a-dire trop tard. Le prix est un instantane plus
/// lourd a publier ; il se voit tout de suite, lui.
function DuplicateEntry(const APlainJson, AUuid: string): string;
/// Fond l'instantane LOCAL dans l'instantane DISTANT et rend le resultat.
///
/// LE DISTANT SERT DE BASE, jamais le local. Il porte ce que le telephone ne
@@ -258,6 +266,58 @@ begin
end;
end;
function DuplicateEntry(const APlainJson, AUuid: string): string;
var
LRoot, LEntry, LCopy: TJSONObject;
LEntries: TJSONArray;
I: Integer;
LNow, LTitle: string;
begin
if AUuid = '' then
raise EPMAPatch.Create('Entree sans uuid : rien a dupliquer');
LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject;
if LRoot = nil then
raise EPMAPatch.Create('Instantane illisible');
try
if not LRoot.TryGetValue<TJSONArray>('entries', LEntries) then
raise EPMAPatch.Create('Instantane sans tableau "entries"');
LCopy := nil;
for I := 0 to LEntries.Count - 1 do
begin
if not (LEntries.Items[I] is TJSONObject) then Continue;
LEntry := LEntries.Items[I] as TJSONObject;
if LEntry.GetValue<string>('uuid', '') <> AUuid then Continue;
// Reparser plutot que recopier champ par champ : c'est le seul moyen qui
// n'oublie rien de ce que le telephone ne comprend pas.
LCopy := CloneJson(LEntry);
Break;
end;
if LCopy = nil then
raise EPMAPatch.CreateFmt('Entree %s introuvable', [AUuid]);
LNow := NowUtcIso;
LCopy.RemovePair('uuid').Free;
LCopy.AddPair('uuid', LowerCase(Copy(GUIDToString(TGUID.NewGuid), 2, 36)));
LCopy.RemovePair('created_at').Free;
LCopy.AddPair('created_at', LNow);
LCopy.RemovePair('updated_at').Free;
LCopy.AddPair('updated_at', LNow);
// Le nom change, sinon les deux lignes sont indiscernables dans la liste.
// Retirer PUIS ajouter, comme partout ici : AddPair seul laisserait deux
// paires 'title', et le lecteur prendrait la premiere - donc l'ancienne.
LTitle := Trim(LCopy.GetValue<string>('title', '') + ' (copy)');
LCopy.RemovePair('title').Free;
LCopy.AddPair('title', LTitle);
LEntries.AddElement(LCopy);
Result := LRoot.ToJSON;
finally
LRoot.Free;
end;
end;
function DeleteEntry(const APlainJson, AUuid: string): string;
var
LRoot, LEntry, LStone: TJSONObject;
+115 -25
View File
@@ -215,6 +215,15 @@
padding: 14px 16px; }
.menu button:active { background: rgba(255,255,255,.10); }
.menu button.danger { color: #ffb4b4; }
/* LA BARRE DE SELECTION prend la place de la recherche, comme dans les
applications d'Android : on ne cherche pas pendant qu'on choisit quoi
supprimer, et deux rangees de commandes se disputeraient le pouce. */
#selbar { display: none; }
body.selecting #selbar { display: flex; }
body.selecting .top { display: none; }
#selcount { flex: 1; font-size: 16px; font-weight: 600; }
/* La ligne choisie se voit, sinon on supprime a l'aveugle. */
.row.sel { outline: 2px solid var(--accent); outline-offset: -2px; }
.field .line { display: flex; align-items: center; gap: 8px; }
.field .val {
flex: 1; min-width: 0; word-break: break-all;
@@ -402,9 +411,14 @@
</svg>
</button>
</div>
<div class="top" id="selbar">
<button class="ico" id="selCancel" aria-label="Cancel selection"></button>
<div id="selcount"></div>
<button class="ico" id="selDup" aria-label="Duplicate"></button>
<button class="ico" id="selDel" aria-label="Delete"></button>
</div>
<div class="folders" id="folders"></div>
<div class="scroll"><div id="rows"></div></div>
<div class="menu" id="menuList" hidden></div>
</div>
<div class="screen" id="detail">
@@ -599,9 +613,13 @@ document.getElementById('rows').addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-copy]');
if (btn) { send('copy?i=' + row.getAttribute('data-i')
+ '&f=' + btn.getAttribute('data-copy')); return; }
// Le menu vient de s'ouvrir sur cette ligne : le clic qui suit l'appui long
// ouvrirait l'entree DERRIERE le menu.
if (!document.getElementById('menuList').hidden) return;
// EN SELECTION, UN APPUI CHOISIT. Ouvrir une entree pendant qu'on en
// designe d'autres n'a pas de sens, et c'est aussi la sortie : deselectionner
// la derniere ligne referme la barre.
if (document.body.classList.contains('selecting')) {
toggleSel(row.getAttribute('data-i'));
return;
}
send('open?i=' + row.getAttribute('data-i'));
});
@@ -650,6 +668,8 @@ var ICONS = {
edit: '<path d="M4 20h4L20 8l-4-4L4 16z"/>',
eye: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/>'
+ '<circle cx="12" cy="12" r="3"/>',
trash: '<path d="M5 7h14M10 7V4.5h4V7M6.5 7l1 12.5h9L18 7"/>'
+ '<path d="M10.5 10.5v6M13.5 10.5v6"/>',
dots: '<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/>'
+ '<circle cx="12" cy="19" r="1.4"/>',
check: '<path d="M4 12.5l5.5 5.5L20 7"/>',
@@ -1049,23 +1069,18 @@ document.getElementById('new').addEventListener('click', openNewEntry);
// pierre tombale fait disparaitre l'entree du poste aussi, sans passer par la
// sienne. Le seul filet est la sauvegarde du poste.
function closeMenu() {
['menu', 'menuList'].forEach(function (id) {
var m = document.getElementById(id);
if (!m) return;
var m = document.getElementById('menu');
m.hidden = true;
m.innerHTML = '';
});
}
/// AMenu vit dans l'ecran courant : le detail a le sien, la liste le sien.
/// ATop, quand il est donne, ancre le menu a la hauteur de la ligne touchee -
/// un menu qui apparait loin du doigt ne se rattache a rien.
function openMenu(AIndex, ATop) {
var m = document.getElementById(view === 'list' ? 'menuList' : 'menu');
m.innerHTML = '<button class="danger" data-del'
+ (AIndex === undefined ? '' : ' data-i="' + AIndex + '"')
+ '>Delete entry</button>';
if (ATop !== undefined) m.style.top = ATop + 'px';
function openMenu() {
var m = document.getElementById('menu');
m.innerHTML = '<button data-dup>Duplicate entry</button>'
+ '<button class="danger" data-del>Delete entry</button>';
m.hidden = false;
}
@@ -1078,6 +1093,11 @@ document.getElementById('kebab').addEventListener('click', function (ev) {
function onMenuClick(ev) {
var b = ev.target.closest('button');
if (!b) return;
if (b.getAttribute('data-dup') !== null) {
closeMenu();
send('dupentry');
return;
}
if (b.getAttribute('data-del') !== null) {
// Le premier appui n'arme que la confirmation. Ouvrir le menu etait deja
// delibere ; ceci ferme la porte a l'appui qui derape dans la liste.
@@ -1091,19 +1111,54 @@ function onMenuClick(ev) {
b.textContent = 'Deleting...';
// L'index designe l'entree quand on supprime depuis la liste. Absent, c'est
// l'entree ouverte - Delphi tient deja laquelle.
var i = b.getAttribute('data-i');
send('delentry' + (i === null ? '' : '?i=' + i));
send('delentry');
}
document.getElementById('menu').addEventListener('click', onMenuClick);
document.getElementById('menuList').addEventListener('click', onMenuClick);
// APPUI LONG POUR SUPPRIMER DEPUIS LA LISTE.
// SELECTIONNER DANS LA LISTE - appui long, puis la barre du haut change.
//
// Le menu flottant qu'il y avait ici se refermait au premier appui suivant,
// donc ouvrir une entree apres coup demandait deux gestes et donnait
// l'impression qu'un bouton restait accroche. La barre contextuelle est le
// modele d'Android : elle prend la place de la recherche, elle dit combien de
// lignes sont prises, et elle disparait quand on n'en a plus.
//
// Ni une croix par ligne - une action sans retour a un pouce d'ecart sur une
// liste qu'on fait defiler - ni un troisieme bouton par ligne, alors qu'on
// vient justement de les degager. L'appui long ne se voit pas tant qu'on ne
// s'en sert pas, c'est la convention d'Android pour agir sur un element de
// liste, et c'est la que se rangera la selection multiple si elle vient.
// vient de les degager.
var selected = {};
function selCount() {
var n = 0, k;
for (k in selected) if (selected[k]) n++;
return n;
}
function renderSel() {
var n = selCount(), i, all = document.querySelectorAll('.row');
document.body.classList.toggle('selecting', n > 0);
document.getElementById('selcount').textContent =
n + (n === 1 ? ' selected' : ' selected');
for (i = 0; i < all.length; i++)
all[i].classList.toggle('sel', !!selected[all[i].getAttribute('data-i')]);
// Delphi doit le savoir : la touche retour ferme la selection avant de
// verrouiller le coffre, sinon un geste d'annulation le fermerait.
send('state?view=' + (n > 0 ? 'select' : 'list'));
}
function clearSel() { selected = {}; renderSel(); }
function toggleSel(i) {
if (selected[i]) delete selected[i]; else selected[i] = true;
renderSel();
}
function selList() {
var out = [], k;
for (k in selected) if (selected[k]) out.push(k);
return out.join(',');
}
(function () {
var timer = null, moved = false;
var rows = document.getElementById('rows');
@@ -1114,10 +1169,9 @@ document.getElementById('menuList').addEventListener('click', onMenuClick);
moved = false;
timer = setTimeout(function () {
if (moved) return;
var r = row.getBoundingClientRect();
openMenu(row.getAttribute('data-i'), Math.round(r.bottom) - 40);
toggleSel(row.getAttribute('data-i'));
send('touch');
}, 600);
}, 500);
});
rows.addEventListener('touchmove', function () { moved = true; clearTimeout(timer); });
['touchend', 'touchcancel'].forEach(function (e) {
@@ -1125,11 +1179,41 @@ document.getElementById('menuList').addEventListener('click', onMenuClick);
});
})();
document.getElementById('selCancel').innerHTML = ico('close', 'Cancel selection');
document.getElementById('selDup').innerHTML = ico('copy', 'Duplicate');
document.getElementById('selDel').innerHTML = ico('trash', 'Delete');
document.getElementById('selCancel').addEventListener('click', clearSel);
document.getElementById('selDup').addEventListener('click', function () {
var l = selList();
if (!l) return;
clearSel();
send('dupentry?i=' + l);
});
document.getElementById('selDel').addEventListener('click', function (ev) {
var b = ev.currentTarget, l = selList();
if (!l) return;
// Deux temps, comme partout ailleurs pour ce geste : il est sans retour.
if (b.getAttribute('data-armed') === null) {
b.setAttribute('data-armed', '1');
b.innerHTML = ico('check', 'Confirm delete');
setTimeout(function () {
b.removeAttribute('data-armed');
b.innerHTML = ico('trash', 'Delete');
}, 4000);
toast('Tap again to delete ' + selCount());
return;
}
b.removeAttribute('data-armed');
b.innerHTML = ico('trash', 'Delete');
clearSel();
send('delentry?i=' + l);
});
// Un appui n'importe ou ailleurs referme le menu - y compris sur le contenu,
// sinon il resterait ouvert pendant qu'on lit l'entree.
document.addEventListener('click', function (ev) {
if (document.getElementById('menu').hidden) return;
if (ev.target.closest('#menu, #menuList, #kebab')) return;
if (ev.target.closest('#menu, #kebab')) return;
closeMenu();
});
@@ -1155,6 +1239,8 @@ var PM = {
q.value = ''; query = '';
document.body.classList.remove('searching', 'on-detail');
document.body.classList.add('on-list');
selected = {};
document.body.classList.remove('selecting');
// LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le
// mot de passe qu'on vient de taper ; le laisser dans le document apres
// l'enregistrement le laisserait la jusqu'au verrouillage.
@@ -1283,6 +1369,10 @@ var PM = {
t._h = setTimeout(function () { t.classList.remove('on'); }, 2600);
},
back: function () { if (view === 'detail') goList(); },
/// La touche retour annule la selection avant de verrouiller le coffre.
/// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce
/// qu'il veut dire.
clearSelection: function () { clearSel(); },
// Efface tout ce qui est en clair. Appele au passage en arriere-plan et au
// verrouillage : le tas JS n'est pas efface par le masquage de la vue.
wipe: function () {
+79 -29
View File
@@ -276,9 +276,12 @@ type
/// comme 'setfield' - meme regle, meme journalisation muette.
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind,
AFolder: string);
/// Supprime une entree, pierre tombale comprise. Sans retour.
/// AVaultIndex negatif = l'entree ouverte.
procedure DeleteEntryAt(AVaultIndex: Integer);
function UuidsFromIndexes(const AList: string): TArray<string>;
/// Supprime des entrees, pierres tombales comprises. Sans retour.
/// Liste vide = l'entree ouverte.
procedure DeleteEntriesAt(const AList: string);
/// Duplique des entrees. Liste vide = l'entree ouverte.
procedure DuplicateEntriesAt(const AList: string);
/// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot.
procedure ReconcileWithRemote;
procedure ReopenEntryByUuid(const AUuid: string);
@@ -504,7 +507,15 @@ begin
end;
// C'est la PAGE qui sait ce qu'elle affiche - Delphi ne le devine pas. Elle
// le declare a chaque changement de vue (cmd://state).
if FWebView = 'detail' then
// UNE SELECTION EN COURS S'ANNULE D'ABORD. Verrouiller le coffre parce qu'on
// a voulu annuler un choix serait le contraire de ce que le geste veut dire.
if FWebView = 'select' then
begin
WebCall('PM.clearSelection()');
FWebView := 'list';
Key := 0;
end
else if FWebView = 'detail' then
begin
WebCall('PM.back()');
FWebView := 'list';
@@ -1174,43 +1185,79 @@ begin
end).Start;
end;
procedure TForm8.DeleteEntryAt(AVaultIndex: Integer);
/// LES RANGS ENVOYES PAR LA PAGE -> LES UUID CORRESPONDANTS.
///
/// Une liste vide veut dire "l'entree ouverte" : c'est le menu du detail, qui
/// n'a pas de rang a donner. La page n'envoie jamais d'uuid - elle en a un,
/// mais le faire voyager reviendrait a lui laisser designer une ligne que
/// Delphi n'a pas affichee.
function TForm8.UuidsFromIndexes(const AList: string): TArray<string>;
var
LUuid, LTitle: string;
E: TVaultEntry;
LPart: string;
LIdx: Integer;
begin
// UN RANG, OU L'ENTREE OUVERTE. Depuis la liste il n'y a pas d'entree
// ouverte : le rang designe laquelle, exactement comme pour 'open' et
// 'copy'. La page n'envoie jamais d'uuid - elle en a un, mais le faire
// voyager reviendrait a lui laisser designer une ligne que Delphi n'a pas
// affichee.
if AVaultIndex >= 0 then
Result := nil;
if Trim(AList) = '' then
begin
if AVaultIndex >= FVault.Count then
begin
RefuseChange('That entry is no longer there');
if FCurrent.Uuid <> '' then Result := [FCurrent.Uuid];
Exit;
end;
E := FVault.Entry(AVaultIndex);
LUuid := E.Uuid;
LTitle := E.Display;
end
else
for LPart in AList.Split([',']) do
begin
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
LIdx := StrToIntDef(Trim(LPart), -1);
if (LIdx < 0) or (LIdx >= FVault.Count) then Continue;
if FVault.Entry(LIdx).Uuid = '' then Continue;
Result := Result + [FVault.Entry(LIdx).Uuid];
end;
if LUuid = '' then
end;
procedure TForm8.DeleteEntriesAt(const AList: string);
var
LUuids: TArray<string>;
begin
LUuids := UuidsFromIndexes(AList);
if Length(LUuids) = 0 then
begin
RefuseChange('This entry has no uuid - cannot be deleted');
RefuseChange('Nothing to delete');
Exit;
end;
// Pas de rang a rouvrir : l'entree n'existera plus. AdoptVault ramene la
// UNE SEULE PUBLICATION POUR TOUTES. Le conteneur monte en entier a chaque
// ecriture : supprimer dix entrees une par une couterait dix fois ce trajet,
// et neuf occasions de le voir echouer a mi-chemin.
//
// Pas de rang a rouvrir : les entrees n'existeront plus. AdoptVault ramene la
// liste, ce qui est exactement ou l'on veut se retrouver.
CommitChange('Deleted: ' + LTitle, 'entry deleted from phone', '',
CommitChange(Format('Deleted %d', [Length(LUuids)]),
'entries deleted from phone', '',
function (APlain: string): string
var
I: Integer;
begin
Result := DeleteEntry(APlain, LUuid);
Result := APlain;
for I := 0 to High(LUuids) do
Result := DeleteEntry(Result, LUuids[I]);
end);
end;
procedure TForm8.DuplicateEntriesAt(const AList: string);
var
LUuids: TArray<string>;
begin
LUuids := UuidsFromIndexes(AList);
if Length(LUuids) = 0 then
begin
RefuseChange('Nothing to duplicate');
Exit;
end;
CommitChange(Format('Duplicated %d', [Length(LUuids)]),
'entries duplicated from phone', '',
function (APlain: string): string
var
I: Integer;
begin
Result := APlain;
for I := 0 to High(LUuids) do
Result := DuplicateEntry(Result, LUuids[I]);
end);
end;
@@ -1378,7 +1425,10 @@ begin
TNetEncoding.URL.Decode(Param('d')))
else if LAction = 'delentry' then
DeleteEntryAt(StrToIntDef(Param('i'), -1))
DeleteEntriesAt(TNetEncoding.URL.Decode(Param('i')))
else if LAction = 'dupentry' then
DuplicateEntriesAt(TNetEncoding.URL.Decode(Param('i')))
else if LAction = 'setfield' then
SaveEntryField(TNetEncoding.URL.Decode(Param('f')),