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
+84 -34
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');
Exit;
end;
E := FVault.Entry(AVaultIndex);
LUuid := E.Uuid;
LTitle := E.Display;
end
else
begin
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
end;
if LUuid = '' then
begin
RefuseChange('This entry has no uuid - cannot be deleted');
if FCurrent.Uuid <> '' then Result := [FCurrent.Uuid];
Exit;
end;
// Pas de rang a rouvrir : l'entree n'existera plus. AdoptVault ramene la
for LPart in AList.Split([',']) do
begin
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;
end;
procedure TForm8.DeleteEntriesAt(const AList: string);
var
LUuids: TArray<string>;
begin
LUuids := UuidsFromIndexes(AList);
if Length(LUuids) = 0 then
begin
RefuseChange('Nothing to delete');
Exit;
end;
// 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')),