feat(app,learn,todo): the operator's "This instance" card, screen and badge (ADR 0021, step 1)
Whoever operates the instance approves, refuses and suspends accounts
and names other operators in the app (D21 to D24, D34):
- halkyon_app: InstanceOperatorRepository on
ServerpodInstanceOperatorGateway (the module's instanceOperator
endpoint; refusals as OperatorFailed: notFound, isOperator,
lastOperator, notAllowed for 401 and 403, failed), in AppDependencies.
OperatingCubit counts the accounts that wait: asked as the person's
app opens, after every change, and every 5 minutes only while they
operate the instance, so nobody else's app keeps asking.
InstanceOperatorCard (or its tiles) and WaitingAccountsBadge show
nothing at all for anyone else. InstanceOperatorScreen, laid out as
Settings: the accounts that wait, each showing its four words to read
out before Approve (D33), Refuse for new ones after asking; who
operates, Take back after asking, the last always kept; Name an
operator by handle; Find an account by the start of its handle, with
Suspend after asking and Name as operator. English and German texts
("Admin" as the role word in German).
- Learn: the card right after the Sync card in Settings, which the
cogs' count leads to as their dot leads to Sync; the count on the
Settings cogs of Home and Profile.
- Todo: the tiles in the account sheet and the count on its button.
Todo's server is a member, whose `operating()` is always null, so
nothing shows there until it holds the register.
Tests: the gateway's mapping, the repository, both cubits with their
denials, the card, the badge and the screen; Learn's Settings shows the
card and the count to an operator and neither to anyone else.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
7ac715cd97
commit
31b9c17850
27 changed files with 2999 additions and 28 deletions
|
|
@ -40,13 +40,33 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
a switch, the previous ones closed first; `SessionGate` shows the app of
|
||||
whoever is signed in and asks how to start while nobody is
|
||||
(`StartScreen`: SimpleLogin, a password, another instance; an instance out of reach is said so, with Try again,
|
||||
and one offering no way to create an account too), at a first start as
|
||||
after signing out; an anonymous account's session, left from before
|
||||
and one offering no way to create an account too; where new accounts wait
|
||||
for the operator's approval, or none are made, it says so first), at a
|
||||
first start as after signing out; while an account signed in or up to
|
||||
here waits for the operator (Halkyon Learn's ADR 0021, D31), `WaitScreen`
|
||||
shows its handle and the four words the operator reads out for the person
|
||||
to match, asks every 30 seconds and as the app comes back, deletes a new
|
||||
account after asking, and once approved leads to signing in once more
|
||||
(the wait kept in the keyring per instance, `SecureAccountWaitStore`, so a
|
||||
restart shows it again); an anonymous account's session, left from before
|
||||
there were none, is deleted as the app starts (ADR 0020, D25), falling
|
||||
back to signing out on the device; `PersonScope` opens each person's own
|
||||
dependencies and closes them when the person goes. `resolveAppLocale`
|
||||
picks the app's language as every Halkyon app does: the first preferred
|
||||
language it speaks, never by country alone, British English otherwise.
|
||||
- **The instance's operators** (`lib/src/operator/`, Halkyon Learn's ADR
|
||||
0021, D21 to D24 and D34): `InstanceOperatorRepository` on
|
||||
`ServerpodInstanceOperatorGateway` (the module's `instanceOperator`
|
||||
endpoint, refusals as `OperatorFailed`), `OperatingCubit` (how many
|
||||
accounts wait, null for anyone who doesn't operate the instance; asked as
|
||||
the person's app opens, after each change, and every 5 minutes only while
|
||||
they operate it), `InstanceOperatorCard` or `InstanceOperatorTiles` for an
|
||||
app's settings and `WaitingAccountsBadge` for its Settings cog, both
|
||||
nothing at all for anyone else, and `InstanceOperatorScreen`: the accounts
|
||||
that wait, approved once their person confirmed the words read out, or
|
||||
refused; who operates, named by handle and taken back (never the last);
|
||||
accounts found by the start of their handle, suspended or named. Only the
|
||||
register's service answers: on a member, nothing shows.
|
||||
- **About** (`showAppAbout`): the app's name (its frame's), its version,
|
||||
icon and legalese where it gives them, where the source of what the
|
||||
instance runs is (`InstanceSourceCode`, from `AppDependencies.sourceCodeUrl`;
|
||||
|
|
@ -174,8 +194,9 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
- **A client of `halkyon_base` alone** (`BaseClient`): to ask an instance
|
||||
about itself, and to sign in as another person than the app's client holds.
|
||||
- **Testing** (`package:halkyon_app/testing.dart`): fakes of every boundary
|
||||
(`FakeAuthGateway`, `FakePasswordGateway`, `FakeAccountCrypto`,
|
||||
`FakeInstanceChecker`, the stores in memory), a frame
|
||||
(`FakeAuthGateway`, with waits, `fakeWaitOf`; `FakePasswordGateway`,
|
||||
`FakeAccountCrypto`, `FakeInstanceChecker`, `FakeInstanceOperatorGateway`,
|
||||
the stores in memory), a frame
|
||||
(`testFrame`), `testApp`, and `TestAppDependencies`.
|
||||
|
||||
## Using it in an app
|
||||
|
|
@ -202,14 +223,19 @@ Written as the apps are: Dart 3.13's primary constructors, Material from
|
|||
(copy Learn's or Todo's `ScreenGuard.kt` and call `ScreenGuard.attach` in
|
||||
`configureFlutterEngine`), or secrets show in screenshots and recents.
|
||||
6. **The dependencies**: the app's composition root implements
|
||||
`AppDependencies<P>` (`serverName`, `auth`, `signedOut`, `passwords`,
|
||||
`fileSaver`, `sourceCodeUrl`, `openPerson`, `dispose`), its person's
|
||||
dependencies `Disposable`. Its `passwords` is a `PasswordRepository` on
|
||||
`AppDependencies<P>` (`serverName`, `auth`, `passwords`, `simpleLogin`,
|
||||
`instanceOperators`, `fileSaver`, `sourceCodeUrl`, `openPerson`,
|
||||
`dispose`), its person's dependencies `Disposable`. Its `passwords` is a `PasswordRepository` on
|
||||
`ServerpodPasswordGateway(client, base: client.modules.base)`,
|
||||
`SodiumAccountCrypto()`, `SecureAccountKeyStore(storage.keysOf(instance))`,
|
||||
the auth gateway and the app's `SignInAddresses`. Its `auth` is an
|
||||
`AuthRepository` on `ServerpodAuthGateway(client, base:
|
||||
client.modules.base)`.
|
||||
client.modules.base, waits:
|
||||
SecureAccountWaitStore(storage.waitOf(instance)))`, whose `initialize()`
|
||||
it awaits before anything asks who is signed in. Its `instanceOperators`
|
||||
is an `InstanceOperatorRepository` on
|
||||
`ServerpodInstanceOperatorGateway(client.modules.base)`; the person's app
|
||||
provides an `OperatingCubit` on it, for the card and the badge.
|
||||
7. **The tree**:
|
||||
|
||||
```dart
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@
|
|||
/// - **The account and About**: `AccountTiles` for an app's settings,
|
||||
/// `showAppAbout` with the source of what the instance runs, and
|
||||
/// `resolveAppLocale`, the language every Halkyon app picks.
|
||||
/// - **The instance's operators**: `InstanceOperatorCard` (or
|
||||
/// `InstanceOperatorTiles`) and `WaitingAccountsBadge` for whoever
|
||||
/// operates the instance, on `OperatingCubit`, leading to
|
||||
/// `InstanceOperatorScreen`, where accounts are approved, refused and
|
||||
/// suspended and operators named (Halkyon Learn's ADR 0021, D21 to D24).
|
||||
/// - **Signing in**: with SimpleLogin (ADR 0020: `SimpleLoginRepository`,
|
||||
/// its sheet and `SimpleLoginSection`), with a password and TOTP (ADR
|
||||
/// 0017: `PasswordRepository`, its sheets and `AccountSecuritySection`),
|
||||
|
|
@ -74,6 +79,14 @@ export 'src/instance/widget/instance_location.dart';
|
|||
export 'src/instance/widget/instance_location_words.dart';
|
||||
export 'src/instance/widget/instance_step.dart';
|
||||
export 'src/instance/widget/instance_tile.dart';
|
||||
export 'src/operator/bloc/instance_operator_cubit.dart';
|
||||
export 'src/operator/bloc/operating_cubit.dart';
|
||||
export 'src/operator/data/instance_operator_gateway.dart';
|
||||
export 'src/operator/data/instance_operator_repository.dart';
|
||||
export 'src/operator/data/serverpod_instance_operator_gateway.dart';
|
||||
export 'src/operator/model/operated_account.dart';
|
||||
export 'src/operator/widget/instance_operator_card.dart';
|
||||
export 'src/operator/widget/instance_operator_screen.dart';
|
||||
export 'src/password/bloc/account_recovery_cubit.dart';
|
||||
export 'src/password/bloc/account_security_cubit.dart';
|
||||
export 'src/password/bloc/account_setup_cubit.dart';
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@
|
|||
"passwordInstanceUnproven": "Die Instanz konnte nicht beweisen, dass sie dein Passwort kennt, daher vertraut dieses Gerät ihr nicht. Versuch es noch einmal; passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
||||
"passwordKeysMissing": "Die Schlüssel deines Kontos sind nicht auf diesem Gerät. Melde dich hier ab und mit deinem Passwort wieder an.",
|
||||
"signUpsClosed": "Diese Instanz nimmt keine neuen Konten an.",
|
||||
"signUpsTooManyWaiting": "Es warten schon so viele neue Konten auf ihre Freigabe, wie dürfen. Versuch es später noch einmal.",
|
||||
"signUpsTooManyWaiting": "Es warten schon zu viele neue Konten auf ihre Freigabe. Versuch es später noch einmal.",
|
||||
"passwordUnsafeCosts": "Die Instanz wollte dein Passwort auf eine Weise prüfen lassen, die kein Konto nutzt, daher wurde nichts gesendet. Passiert es wieder, ist die Instanz nicht die, die sie zu sein behauptet.",
|
||||
"passwordFailed": "Die Instanz war nicht erreichbar. Prüf die Verbindung und versuch es noch einmal.",
|
||||
"passwordMinLength": "Mindestens {count} Zeichen",
|
||||
|
|
@ -309,5 +309,41 @@
|
|||
"simpleLoginSectionTitle": "SimpleLogin",
|
||||
"simpleLoginSignsYouIn": "Meldet dich an",
|
||||
"simpleLoginAddHint": "Melde dich mit SimpleLogin an, oder mit deinem Proton-Konto.",
|
||||
"simpleLoginAdded": "SimpleLogin hinzugefügt."
|
||||
"simpleLoginAdded": "SimpleLogin hinzugefügt.",
|
||||
"operatorTitle": "Diese Instanz",
|
||||
"operatorAccounts": "Konten und Admins",
|
||||
"operatorWaitingCount": "{count, plural, =0{Niemand wartet auf Freigabe} =1{1 Konto wartet auf Freigabe} other{{count} Konten warten auf Freigabe}}",
|
||||
"operatorWaitingTitle": "Wartend",
|
||||
"operatorNobodyWaits": "Niemand wartet.",
|
||||
"operatorNoHandle": "Noch kein Handle",
|
||||
"operatorNewSince": "Neu, wartet seit {date}",
|
||||
"operatorSuspendedSince": "Gesperrt seit {date}",
|
||||
"operatorApproveNote": "Lies diese vier Wörter vor, und gib das Konto erst frei, wenn dir bestätigt wird, dass genau diese auf dem Bildschirm stehen.",
|
||||
"operatorApprove": "Freigeben",
|
||||
"operatorRefuse": "Ablehnen",
|
||||
"operatorRefuseTitle": "Dieses Konto ablehnen?",
|
||||
"operatorRefuseMessage": "Es wird mit allem gelöscht, was darin ist. Eine neue Anmeldung bleibt möglich.",
|
||||
"operatorOperatorsTitle": "Admins",
|
||||
"operatorTakeBack": "Entziehen",
|
||||
"operatorTakeBackTitle": "Die Rolle entziehen?",
|
||||
"operatorTakeBackMessage": "Das Konto bleibt, kann hier aber keine Konten mehr freigeben, ablehnen oder sperren.",
|
||||
"operatorAppoint": "Admin ernennen",
|
||||
"operatorAppointMessage": "Admins geben hier Konten frei, lehnen sie ab, sperren sie und ernennen Admins, so wie du.",
|
||||
"operatorAppointConfirm": "Ernennen",
|
||||
"operatorHandle": "Handle",
|
||||
"operatorFindTitle": "Ein Konto finden",
|
||||
"operatorFoundNone": "Kein Handle beginnt so.",
|
||||
"operatorMadeOn": "Seit {date}",
|
||||
"operatorIsOperator": "Admin",
|
||||
"operatorIsWaiting": "Wartend",
|
||||
"operatorIsSuspended": "Gesperrt",
|
||||
"operatorSuspend": "Sperren",
|
||||
"operatorSuspendTitle": "Dieses Konto sperren?",
|
||||
"operatorSuspendMessage": "Es wird überall abgemeldet und kann sich erst wieder anmelden, wenn du es freigibst. Nichts darin wird gelöscht.",
|
||||
"operatorMakeOperator": "Zum Admin ernennen",
|
||||
"operatorNotFound": "Dieses Konto gibt es nicht mehr, oder es wartet nicht.",
|
||||
"operatorIsOperatorProblem": "Admins können nicht gesperrt werden. Entzieh zuerst die Rolle.",
|
||||
"operatorLastOperator": "Die Instanz braucht mindestens einen Admin.",
|
||||
"operatorNotAllowed": "Das können nur die Admins der Instanz.",
|
||||
"operatorFailed": "Das hat nicht geklappt. Versuch es noch einmal."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -541,7 +541,7 @@
|
|||
"@signUpsClosed": {
|
||||
"description": "A sign-up refused: the operator takes no new accounts (D19)."
|
||||
},
|
||||
"signUpsTooManyWaiting": "As many new accounts wait for approval as may. Try again later.",
|
||||
"signUpsTooManyWaiting": "Too many new accounts already wait for approval. Try again later.",
|
||||
"@signUpsTooManyWaiting": {
|
||||
"description": "A sign-up refused: ten new accounts wait for the operator's approval already (D19)."
|
||||
},
|
||||
|
|
@ -742,5 +742,169 @@
|
|||
"simpleLoginSectionTitle": "SimpleLogin",
|
||||
"simpleLoginSignsYouIn": "Signs you in",
|
||||
"simpleLoginAddHint": "Sign in with SimpleLogin, or with your Proton account.",
|
||||
"simpleLoginAdded": "SimpleLogin added."
|
||||
"simpleLoginAdded": "SimpleLogin added.",
|
||||
"operatorTitle": "This instance",
|
||||
"@operatorTitle": {
|
||||
"description": "The card and screen for whoever operates the instance (ADR 0021, D34)."
|
||||
},
|
||||
"operatorAccounts": "Accounts and operators",
|
||||
"@operatorAccounts": {
|
||||
"description": "The tile leading to the operator's screen."
|
||||
},
|
||||
"operatorWaitingCount": "{count, plural, =0{Nobody waits for approval} =1{1 account waits for approval} other{{count} accounts wait for approval}}",
|
||||
"@operatorWaitingCount": {
|
||||
"description": "How many accounts wait for the operator; also the badge's label.",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"operatorWaitingTitle": "Waiting",
|
||||
"@operatorWaitingTitle": {
|
||||
"description": "The card of accounts that wait for approval, new or suspended."
|
||||
},
|
||||
"operatorNobodyWaits": "Nobody waits.",
|
||||
"@operatorNobodyWaits": {
|
||||
"description": "The waiting card, empty."
|
||||
},
|
||||
"operatorNoHandle": "No handle yet",
|
||||
"@operatorNoHandle": {
|
||||
"description": "An account without a handle, where its handle would be."
|
||||
},
|
||||
"operatorNewSince": "New, waiting since {date}",
|
||||
"@operatorNewSince": {
|
||||
"description": "A new account that waits, and since when.",
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"operatorSuspendedSince": "Suspended since {date}",
|
||||
"@operatorSuspendedSince": {
|
||||
"description": "A suspended account, and since when.",
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"operatorApproveNote": "Read them these four words, and approve only once they confirm these are on their screen.",
|
||||
"@operatorApproveNote": {
|
||||
"description": "Before approving: the words prove it is their account that waits (D33)."
|
||||
},
|
||||
"operatorApprove": "Approve",
|
||||
"@operatorApprove": {
|
||||
"description": "Approves a waiting account."
|
||||
},
|
||||
"operatorRefuse": "Refuse",
|
||||
"@operatorRefuse": {
|
||||
"description": "Refuses a new account: deleted."
|
||||
},
|
||||
"operatorRefuseTitle": "Refuse this account?",
|
||||
"@operatorRefuseTitle": {
|
||||
"description": "The question before refusing."
|
||||
},
|
||||
"operatorRefuseMessage": "It is deleted with everything it has. They can sign up again.",
|
||||
"@operatorRefuseMessage": {
|
||||
"description": "What refusing does."
|
||||
},
|
||||
"operatorOperatorsTitle": "Operators",
|
||||
"@operatorOperatorsTitle": {
|
||||
"description": "The card of who operates the instance."
|
||||
},
|
||||
"operatorTakeBack": "Take back",
|
||||
"@operatorTakeBack": {
|
||||
"description": "Takes back the operator's role."
|
||||
},
|
||||
"operatorTakeBackTitle": "Take back the role?",
|
||||
"@operatorTakeBackTitle": {
|
||||
"description": "The question before taking the role back."
|
||||
},
|
||||
"operatorTakeBackMessage": "They keep their account, but no longer approve, refuse or suspend accounts here.",
|
||||
"@operatorTakeBackMessage": {
|
||||
"description": "What taking the role back does."
|
||||
},
|
||||
"operatorAppoint": "Name an operator",
|
||||
"@operatorAppoint": {
|
||||
"description": "Names an account an operator, by its handle."
|
||||
},
|
||||
"operatorAppointMessage": "They approve, refuse and suspend accounts here, and name operators, as you do.",
|
||||
"@operatorAppointMessage": {
|
||||
"description": "What an operator may do."
|
||||
},
|
||||
"operatorAppointConfirm": "Name",
|
||||
"@operatorAppointConfirm": {
|
||||
"description": "Confirms naming an operator."
|
||||
},
|
||||
"operatorHandle": "Handle",
|
||||
"@operatorHandle": {
|
||||
"description": "The field for an account's handle."
|
||||
},
|
||||
"operatorFindTitle": "Find an account",
|
||||
"@operatorFindTitle": {
|
||||
"description": "The card searching accounts by the start of their handle."
|
||||
},
|
||||
"operatorFoundNone": "No account's handle starts like this.",
|
||||
"@operatorFoundNone": {
|
||||
"description": "A search that found nothing."
|
||||
},
|
||||
"operatorMadeOn": "Since {date}",
|
||||
"@operatorMadeOn": {
|
||||
"description": "When an account was made.",
|
||||
"placeholders": {
|
||||
"date": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"operatorIsOperator": "Operator",
|
||||
"@operatorIsOperator": {
|
||||
"description": "A tag: the account operates the instance."
|
||||
},
|
||||
"operatorIsWaiting": "Waiting",
|
||||
"@operatorIsWaiting": {
|
||||
"description": "A tag: the account waits for approval."
|
||||
},
|
||||
"operatorIsSuspended": "Suspended",
|
||||
"@operatorIsSuspended": {
|
||||
"description": "A tag: the account is suspended."
|
||||
},
|
||||
"operatorSuspend": "Suspend",
|
||||
"@operatorSuspend": {
|
||||
"description": "Suspends an account."
|
||||
},
|
||||
"operatorSuspendTitle": "Suspend this account?",
|
||||
"@operatorSuspendTitle": {
|
||||
"description": "The question before suspending."
|
||||
},
|
||||
"operatorSuspendMessage": "They are signed out everywhere and can't sign in until you approve them again. Nothing they have is deleted.",
|
||||
"@operatorSuspendMessage": {
|
||||
"description": "What suspending does."
|
||||
},
|
||||
"operatorMakeOperator": "Name as operator",
|
||||
"@operatorMakeOperator": {
|
||||
"description": "Names a found account an operator."
|
||||
},
|
||||
"operatorNotFound": "That account isn't there any more, or doesn't wait.",
|
||||
"@operatorNotFound": {
|
||||
"description": "Refused: no such account, or it doesn't wait."
|
||||
},
|
||||
"operatorIsOperatorProblem": "An operator can't be suspended. Take back their role first.",
|
||||
"@operatorIsOperatorProblem": {
|
||||
"description": "Refused: suspending an operator."
|
||||
},
|
||||
"operatorLastOperator": "The instance needs at least one operator.",
|
||||
"@operatorLastOperator": {
|
||||
"description": "Refused: taking back the last operator's role."
|
||||
},
|
||||
"operatorNotAllowed": "Only the instance's operators can do this.",
|
||||
"@operatorNotAllowed": {
|
||||
"description": "Refused: the person doesn't operate the instance."
|
||||
},
|
||||
"operatorFailed": "That didn't work. Try again.",
|
||||
"@operatorFailed": {
|
||||
"description": "Any other failure."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1402,7 +1402,7 @@ abstract class HalkyonAppLocalizations {
|
|||
/// A sign-up refused: ten new accounts wait for the operator's approval already (D19).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'As many new accounts wait for approval as may. Try again later.'**
|
||||
/// **'Too many new accounts already wait for approval. Try again later.'**
|
||||
String get signUpsTooManyWaiting;
|
||||
|
||||
/// No description provided for @passwordUnsafeCosts.
|
||||
|
|
@ -1962,6 +1962,222 @@ abstract class HalkyonAppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'SimpleLogin added.'**
|
||||
String get simpleLoginAdded;
|
||||
|
||||
/// The card and screen for whoever operates the instance (ADR 0021, D34).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This instance'**
|
||||
String get operatorTitle;
|
||||
|
||||
/// The tile leading to the operator's screen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Accounts and operators'**
|
||||
String get operatorAccounts;
|
||||
|
||||
/// How many accounts wait for the operator; also the badge's label.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =0{Nobody waits for approval} =1{1 account waits for approval} other{{count} accounts wait for approval}}'**
|
||||
String operatorWaitingCount(int count);
|
||||
|
||||
/// The card of accounts that wait for approval, new or suspended.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Waiting'**
|
||||
String get operatorWaitingTitle;
|
||||
|
||||
/// The waiting card, empty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Nobody waits.'**
|
||||
String get operatorNobodyWaits;
|
||||
|
||||
/// An account without a handle, where its handle would be.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No handle yet'**
|
||||
String get operatorNoHandle;
|
||||
|
||||
/// A new account that waits, and since when.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'New, waiting since {date}'**
|
||||
String operatorNewSince(String date);
|
||||
|
||||
/// A suspended account, and since when.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suspended since {date}'**
|
||||
String operatorSuspendedSince(String date);
|
||||
|
||||
/// Before approving: the words prove it is their account that waits (D33).
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Read them these four words, and approve only once they confirm these are on their screen.'**
|
||||
String get operatorApproveNote;
|
||||
|
||||
/// Approves a waiting account.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Approve'**
|
||||
String get operatorApprove;
|
||||
|
||||
/// Refuses a new account: deleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refuse'**
|
||||
String get operatorRefuse;
|
||||
|
||||
/// The question before refusing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Refuse this account?'**
|
||||
String get operatorRefuseTitle;
|
||||
|
||||
/// What refusing does.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It is deleted with everything it has. They can sign up again.'**
|
||||
String get operatorRefuseMessage;
|
||||
|
||||
/// The card of who operates the instance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Operators'**
|
||||
String get operatorOperatorsTitle;
|
||||
|
||||
/// Takes back the operator's role.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take back'**
|
||||
String get operatorTakeBack;
|
||||
|
||||
/// The question before taking the role back.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Take back the role?'**
|
||||
String get operatorTakeBackTitle;
|
||||
|
||||
/// What taking the role back does.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They keep their account, but no longer approve, refuse or suspend accounts here.'**
|
||||
String get operatorTakeBackMessage;
|
||||
|
||||
/// Names an account an operator, by its handle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name an operator'**
|
||||
String get operatorAppoint;
|
||||
|
||||
/// What an operator may do.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They approve, refuse and suspend accounts here, and name operators, as you do.'**
|
||||
String get operatorAppointMessage;
|
||||
|
||||
/// Confirms naming an operator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name'**
|
||||
String get operatorAppointConfirm;
|
||||
|
||||
/// The field for an account's handle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Handle'**
|
||||
String get operatorHandle;
|
||||
|
||||
/// The card searching accounts by the start of their handle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Find an account'**
|
||||
String get operatorFindTitle;
|
||||
|
||||
/// A search that found nothing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No account\'s handle starts like this.'**
|
||||
String get operatorFoundNone;
|
||||
|
||||
/// When an account was made.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Since {date}'**
|
||||
String operatorMadeOn(String date);
|
||||
|
||||
/// A tag: the account operates the instance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Operator'**
|
||||
String get operatorIsOperator;
|
||||
|
||||
/// A tag: the account waits for approval.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Waiting'**
|
||||
String get operatorIsWaiting;
|
||||
|
||||
/// A tag: the account is suspended.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suspended'**
|
||||
String get operatorIsSuspended;
|
||||
|
||||
/// Suspends an account.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suspend'**
|
||||
String get operatorSuspend;
|
||||
|
||||
/// The question before suspending.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Suspend this account?'**
|
||||
String get operatorSuspendTitle;
|
||||
|
||||
/// What suspending does.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'They are signed out everywhere and can\'t sign in until you approve them again. Nothing they have is deleted.'**
|
||||
String get operatorSuspendMessage;
|
||||
|
||||
/// Names a found account an operator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Name as operator'**
|
||||
String get operatorMakeOperator;
|
||||
|
||||
/// Refused: no such account, or it doesn't wait.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That account isn\'t there any more, or doesn\'t wait.'**
|
||||
String get operatorNotFound;
|
||||
|
||||
/// Refused: suspending an operator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'An operator can\'t be suspended. Take back their role first.'**
|
||||
String get operatorIsOperatorProblem;
|
||||
|
||||
/// Refused: taking back the last operator's role.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The instance needs at least one operator.'**
|
||||
String get operatorLastOperator;
|
||||
|
||||
/// Refused: the person doesn't operate the instance.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Only the instance\'s operators can do this.'**
|
||||
String get operatorNotAllowed;
|
||||
|
||||
/// Any other failure.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'That didn\'t work. Try again.'**
|
||||
String get operatorFailed;
|
||||
}
|
||||
|
||||
class _HalkyonAppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -821,7 +821,7 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
|
||||
@override
|
||||
String get signUpsTooManyWaiting =>
|
||||
'Es warten schon so viele neue Konten auf ihre Freigabe, wie dürfen. Versuch es später noch einmal.';
|
||||
'Es warten schon zu viele neue Konten auf ihre Freigabe. Versuch es später noch einmal.';
|
||||
|
||||
@override
|
||||
String get passwordUnsafeCosts =>
|
||||
|
|
@ -1172,4 +1172,136 @@ class HalkyonAppLocalizationsDe extends HalkyonAppLocalizations {
|
|||
|
||||
@override
|
||||
String get simpleLoginAdded => 'SimpleLogin hinzugefügt.';
|
||||
|
||||
@override
|
||||
String get operatorTitle => 'Diese Instanz';
|
||||
|
||||
@override
|
||||
String get operatorAccounts => 'Konten und Admins';
|
||||
|
||||
@override
|
||||
String operatorWaitingCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Konten warten auf Freigabe',
|
||||
one: '1 Konto wartet auf Freigabe',
|
||||
zero: 'Niemand wartet auf Freigabe',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get operatorWaitingTitle => 'Wartend';
|
||||
|
||||
@override
|
||||
String get operatorNobodyWaits => 'Niemand wartet.';
|
||||
|
||||
@override
|
||||
String get operatorNoHandle => 'Noch kein Handle';
|
||||
|
||||
@override
|
||||
String operatorNewSince(String date) {
|
||||
return 'Neu, wartet seit $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String operatorSuspendedSince(String date) {
|
||||
return 'Gesperrt seit $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get operatorApproveNote =>
|
||||
'Lies diese vier Wörter vor, und gib das Konto erst frei, wenn dir bestätigt wird, dass genau diese auf dem Bildschirm stehen.';
|
||||
|
||||
@override
|
||||
String get operatorApprove => 'Freigeben';
|
||||
|
||||
@override
|
||||
String get operatorRefuse => 'Ablehnen';
|
||||
|
||||
@override
|
||||
String get operatorRefuseTitle => 'Dieses Konto ablehnen?';
|
||||
|
||||
@override
|
||||
String get operatorRefuseMessage =>
|
||||
'Es wird mit allem gelöscht, was darin ist. Eine neue Anmeldung bleibt möglich.';
|
||||
|
||||
@override
|
||||
String get operatorOperatorsTitle => 'Admins';
|
||||
|
||||
@override
|
||||
String get operatorTakeBack => 'Entziehen';
|
||||
|
||||
@override
|
||||
String get operatorTakeBackTitle => 'Die Rolle entziehen?';
|
||||
|
||||
@override
|
||||
String get operatorTakeBackMessage =>
|
||||
'Das Konto bleibt, kann hier aber keine Konten mehr freigeben, ablehnen oder sperren.';
|
||||
|
||||
@override
|
||||
String get operatorAppoint => 'Admin ernennen';
|
||||
|
||||
@override
|
||||
String get operatorAppointMessage =>
|
||||
'Admins geben hier Konten frei, lehnen sie ab, sperren sie und ernennen Admins, so wie du.';
|
||||
|
||||
@override
|
||||
String get operatorAppointConfirm => 'Ernennen';
|
||||
|
||||
@override
|
||||
String get operatorHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String get operatorFindTitle => 'Ein Konto finden';
|
||||
|
||||
@override
|
||||
String get operatorFoundNone => 'Kein Handle beginnt so.';
|
||||
|
||||
@override
|
||||
String operatorMadeOn(String date) {
|
||||
return 'Seit $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get operatorIsOperator => 'Admin';
|
||||
|
||||
@override
|
||||
String get operatorIsWaiting => 'Wartend';
|
||||
|
||||
@override
|
||||
String get operatorIsSuspended => 'Gesperrt';
|
||||
|
||||
@override
|
||||
String get operatorSuspend => 'Sperren';
|
||||
|
||||
@override
|
||||
String get operatorSuspendTitle => 'Dieses Konto sperren?';
|
||||
|
||||
@override
|
||||
String get operatorSuspendMessage =>
|
||||
'Es wird überall abgemeldet und kann sich erst wieder anmelden, wenn du es freigibst. Nichts darin wird gelöscht.';
|
||||
|
||||
@override
|
||||
String get operatorMakeOperator => 'Zum Admin ernennen';
|
||||
|
||||
@override
|
||||
String get operatorNotFound =>
|
||||
'Dieses Konto gibt es nicht mehr, oder es wartet nicht.';
|
||||
|
||||
@override
|
||||
String get operatorIsOperatorProblem =>
|
||||
'Admins können nicht gesperrt werden. Entzieh zuerst die Rolle.';
|
||||
|
||||
@override
|
||||
String get operatorLastOperator =>
|
||||
'Die Instanz braucht mindestens einen Admin.';
|
||||
|
||||
@override
|
||||
String get operatorNotAllowed => 'Das können nur die Admins der Instanz.';
|
||||
|
||||
@override
|
||||
String get operatorFailed =>
|
||||
'Das hat nicht geklappt. Versuch es noch einmal.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -803,7 +803,7 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
|
||||
@override
|
||||
String get signUpsTooManyWaiting =>
|
||||
'As many new accounts wait for approval as may. Try again later.';
|
||||
'Too many new accounts already wait for approval. Try again later.';
|
||||
|
||||
@override
|
||||
String get passwordUnsafeCosts =>
|
||||
|
|
@ -1151,6 +1151,138 @@ class HalkyonAppLocalizationsEn extends HalkyonAppLocalizations {
|
|||
|
||||
@override
|
||||
String get simpleLoginAdded => 'SimpleLogin added.';
|
||||
|
||||
@override
|
||||
String get operatorTitle => 'This instance';
|
||||
|
||||
@override
|
||||
String get operatorAccounts => 'Accounts and operators';
|
||||
|
||||
@override
|
||||
String operatorWaitingCount(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count accounts wait for approval',
|
||||
one: '1 account waits for approval',
|
||||
zero: 'Nobody waits for approval',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get operatorWaitingTitle => 'Waiting';
|
||||
|
||||
@override
|
||||
String get operatorNobodyWaits => 'Nobody waits.';
|
||||
|
||||
@override
|
||||
String get operatorNoHandle => 'No handle yet';
|
||||
|
||||
@override
|
||||
String operatorNewSince(String date) {
|
||||
return 'New, waiting since $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String operatorSuspendedSince(String date) {
|
||||
return 'Suspended since $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get operatorApproveNote =>
|
||||
'Read them these four words, and approve only once they confirm these are on their screen.';
|
||||
|
||||
@override
|
||||
String get operatorApprove => 'Approve';
|
||||
|
||||
@override
|
||||
String get operatorRefuse => 'Refuse';
|
||||
|
||||
@override
|
||||
String get operatorRefuseTitle => 'Refuse this account?';
|
||||
|
||||
@override
|
||||
String get operatorRefuseMessage =>
|
||||
'It is deleted with everything it has. They can sign up again.';
|
||||
|
||||
@override
|
||||
String get operatorOperatorsTitle => 'Operators';
|
||||
|
||||
@override
|
||||
String get operatorTakeBack => 'Take back';
|
||||
|
||||
@override
|
||||
String get operatorTakeBackTitle => 'Take back the role?';
|
||||
|
||||
@override
|
||||
String get operatorTakeBackMessage =>
|
||||
'They keep their account, but no longer approve, refuse or suspend accounts here.';
|
||||
|
||||
@override
|
||||
String get operatorAppoint => 'Name an operator';
|
||||
|
||||
@override
|
||||
String get operatorAppointMessage =>
|
||||
'They approve, refuse and suspend accounts here, and name operators, as you do.';
|
||||
|
||||
@override
|
||||
String get operatorAppointConfirm => 'Name';
|
||||
|
||||
@override
|
||||
String get operatorHandle => 'Handle';
|
||||
|
||||
@override
|
||||
String get operatorFindTitle => 'Find an account';
|
||||
|
||||
@override
|
||||
String get operatorFoundNone => 'No account\'s handle starts like this.';
|
||||
|
||||
@override
|
||||
String operatorMadeOn(String date) {
|
||||
return 'Since $date';
|
||||
}
|
||||
|
||||
@override
|
||||
String get operatorIsOperator => 'Operator';
|
||||
|
||||
@override
|
||||
String get operatorIsWaiting => 'Waiting';
|
||||
|
||||
@override
|
||||
String get operatorIsSuspended => 'Suspended';
|
||||
|
||||
@override
|
||||
String get operatorSuspend => 'Suspend';
|
||||
|
||||
@override
|
||||
String get operatorSuspendTitle => 'Suspend this account?';
|
||||
|
||||
@override
|
||||
String get operatorSuspendMessage =>
|
||||
'They are signed out everywhere and can\'t sign in until you approve them again. Nothing they have is deleted.';
|
||||
|
||||
@override
|
||||
String get operatorMakeOperator => 'Name as operator';
|
||||
|
||||
@override
|
||||
String get operatorNotFound =>
|
||||
'That account isn\'t there any more, or doesn\'t wait.';
|
||||
|
||||
@override
|
||||
String get operatorIsOperatorProblem =>
|
||||
'An operator can\'t be suspended. Take back their role first.';
|
||||
|
||||
@override
|
||||
String get operatorLastOperator =>
|
||||
'The instance needs at least one operator.';
|
||||
|
||||
@override
|
||||
String get operatorNotAllowed =>
|
||||
'Only the instance\'s operators can do this.';
|
||||
|
||||
@override
|
||||
String get operatorFailed => 'That didn\'t work. Try again.';
|
||||
}
|
||||
|
||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
|
|||
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_app/src/files/file_saver.dart';
|
||||
import 'package:halkyon_app/src/operator/data/instance_operator_repository.dart';
|
||||
import 'package:halkyon_app/src/password/data/password_repository.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
|
||||
|
||||
|
|
@ -33,6 +34,11 @@ abstract interface class AppDependencies<P extends Disposable>()
|
|||
/// (ADR 0020).
|
||||
SimpleLoginRepository get simpleLogin;
|
||||
|
||||
/// What the instance's operators do there (Halkyon Learn's ADR 0021,
|
||||
/// D21 to D24): the accounts that wait, approved or refused, accounts
|
||||
/// suspended, and who operates; refused to anyone else.
|
||||
InstanceOperatorRepository get instanceOperators;
|
||||
|
||||
/// Keeps a file the app made where the person chooses (a recovery phrase,
|
||||
/// recovery codes, an app's exports).
|
||||
FileSaver get fileSaver;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/src/operator/data/instance_operator_repository.dart';
|
||||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
|
||||
/// What the operator's screen shows.
|
||||
final class const InstanceOperatorState({
|
||||
/// Reading the lists.
|
||||
final bool loading = true,
|
||||
|
||||
/// Why they couldn't be read; null while they could.
|
||||
final OperatorFailed? unreadable,
|
||||
final List<OperatedAccount> waiting = const [],
|
||||
final List<OperatedAccount> operators = const [],
|
||||
|
||||
/// The start of a handle typed to find accounts by.
|
||||
final String query = '',
|
||||
final List<OperatedAccount> found = const [],
|
||||
|
||||
/// What is under way: the ID of the account acted on, or the handle
|
||||
/// being named an operator; null for nothing.
|
||||
final String? busy,
|
||||
|
||||
/// Why the last action failed, nothing changed; null for none.
|
||||
final OperatorFailed? problem,
|
||||
}) with Equatable {
|
||||
InstanceOperatorState copyWith({
|
||||
bool? loading,
|
||||
OperatorFailed? unreadable,
|
||||
bool readable = false,
|
||||
List<OperatedAccount>? waiting,
|
||||
List<OperatedAccount>? operators,
|
||||
String? query,
|
||||
List<OperatedAccount>? found,
|
||||
String? busy,
|
||||
bool clearBusy = false,
|
||||
OperatorFailed? problem,
|
||||
bool clearProblem = false,
|
||||
}) => InstanceOperatorState(
|
||||
loading: loading ?? this.loading,
|
||||
unreadable: readable ? null : unreadable ?? this.unreadable,
|
||||
waiting: waiting ?? this.waiting,
|
||||
operators: operators ?? this.operators,
|
||||
query: query ?? this.query,
|
||||
found: found ?? this.found,
|
||||
busy: clearBusy ? null : busy ?? this.busy,
|
||||
problem: clearProblem ? null : problem ?? this.problem,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
loading,
|
||||
unreadable,
|
||||
waiting,
|
||||
operators,
|
||||
query,
|
||||
found,
|
||||
busy,
|
||||
problem,
|
||||
];
|
||||
}
|
||||
|
||||
/// The operator's screen (Halkyon Learn's ADR 0021, D21 to D24): the
|
||||
/// accounts that wait, approved once their person confirmed the words read
|
||||
/// out (D33) or refused; who operates, named and taken back; accounts found
|
||||
/// by the start of their handle, suspended or named operators. Each action
|
||||
/// one at a time, the lists read anew after it.
|
||||
class InstanceOperatorCubit(final InstanceOperatorRepository _operators)
|
||||
extends Cubit<InstanceOperatorState> {
|
||||
this : super(const InstanceOperatorState());
|
||||
|
||||
/// Which search answered last, so an older answer arriving late is
|
||||
/// dropped.
|
||||
var _searches = 0;
|
||||
|
||||
/// Reads the accounts that wait and who operates.
|
||||
Future<void> load() async {
|
||||
try {
|
||||
// Not a record's `wait`, whose error hides each refusal in its own.
|
||||
final [waiting, operators] = await Future.wait([
|
||||
_operators.waiting(),
|
||||
_operators.operators(),
|
||||
]);
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
readable: true,
|
||||
waiting: waiting,
|
||||
operators: operators,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Reading the accounts failed: ${error.runtimeType}', name: 'Auth');
|
||||
if (!isClosed) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
loading: false,
|
||||
unreadable: error is OperatorFailed
|
||||
? error
|
||||
: const OperatorFailed(OperatorProblem.failed),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the accounts whose handle starts with [query], as it is typed.
|
||||
Future<void> find(String query) async {
|
||||
final search = ++_searches;
|
||||
emit(state.copyWith(query: query));
|
||||
try {
|
||||
final found = await _operators.accountsStartingWith(query);
|
||||
if (!isClosed && search == _searches) {
|
||||
emit(state.copyWith(found: found));
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Finding accounts failed: ${error.runtimeType}', name: 'Auth');
|
||||
if (!isClosed && search == _searches) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
found: const [],
|
||||
problem: error is OperatorFailed
|
||||
? error
|
||||
: const OperatorFailed(OperatorProblem.failed),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Approves [account], new or suspended.
|
||||
Future<void> approve(String account) =>
|
||||
_act(account, () => _operators.approve(account));
|
||||
|
||||
/// Refuses [account], new: deleted with all it has.
|
||||
Future<void> refuse(String account) =>
|
||||
_act(account, () => _operators.refuse(account));
|
||||
|
||||
/// Suspends [account].
|
||||
Future<void> suspend(String account) =>
|
||||
_act(account, () => _operators.suspend(account));
|
||||
|
||||
/// Names the account with [handle] an operator.
|
||||
Future<void> appoint(String handle) =>
|
||||
_act(handle, () => _operators.appoint(handle));
|
||||
|
||||
/// Takes the role back from [account].
|
||||
Future<void> takeBack(String account) =>
|
||||
_act(account, () => _operators.takeBack(account));
|
||||
|
||||
Future<void> _act(String what, Future<void> Function() action) async {
|
||||
if (state.busy != null) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(busy: what, clearProblem: true));
|
||||
OperatorFailed? failed;
|
||||
try {
|
||||
await action();
|
||||
} on Object catch (error) {
|
||||
log('An operator action failed: ${error.runtimeType}', name: 'Auth');
|
||||
failed = error is OperatorFailed
|
||||
? error
|
||||
: const OperatorFailed(OperatorProblem.failed);
|
||||
}
|
||||
await load();
|
||||
if (state.query.isNotEmpty) {
|
||||
await find(state.query);
|
||||
}
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(clearBusy: true, problem: failed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/src/operator/data/instance_operator_repository.dart';
|
||||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
|
||||
/// How many accounts wait for the signed-in person, where they operate the
|
||||
/// instance (Halkyon Learn's ADR 0021, D34): null where they don't, or
|
||||
/// while it isn't known. Asked as it opens, after each change made here,
|
||||
/// whenever asked ([refresh]) and, while they operate it, every [every]:
|
||||
/// nobody else's app keeps asking, and someone named an operator meanwhile
|
||||
/// sees it the next time their app opens. For the badge on an app's
|
||||
/// Settings and its "This instance" card.
|
||||
class OperatingCubit(
|
||||
final InstanceOperatorRepository _operators, {
|
||||
final Duration every = const Duration(minutes: 5),
|
||||
}) extends Cubit<int?> {
|
||||
this : super(null) {
|
||||
_changes = _operators.changes.listen((_) => unawaited(refresh()));
|
||||
_timer = Timer.periodic(every, (_) {
|
||||
if (state != null) {
|
||||
unawaited(refresh());
|
||||
}
|
||||
});
|
||||
unawaited(refresh());
|
||||
}
|
||||
|
||||
late final StreamSubscription<void> _changes;
|
||||
late final Timer _timer;
|
||||
|
||||
/// Asks the instance anew; out of reach, what was known stays.
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final waiting = await _operators.operating();
|
||||
if (!isClosed) {
|
||||
emit(waiting);
|
||||
}
|
||||
} on OperatorFailed catch (failed) {
|
||||
if (failed.problem == OperatorProblem.notAllowed && !isClosed) {
|
||||
emit(null);
|
||||
}
|
||||
} on Object catch (error) {
|
||||
log('Counting the waiting failed: ${error.runtimeType}', name: 'Auth');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_timer.cancel();
|
||||
unawaited(_changes.cancel());
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
|
||||
/// The boundary to what the instance's operators do there (Halkyon Learn's
|
||||
/// ADR 0021, D21 to D24), on the signed-in person's session: refusals are
|
||||
/// [OperatorFailed]s. Tests replace it with a fake.
|
||||
abstract interface class InstanceOperatorGateway() {
|
||||
/// How many accounts wait, new or suspended, where the signed-in person
|
||||
/// operates the instance; null where they don't, or where it is a member
|
||||
/// of another's register (D34).
|
||||
Future<int?> operating();
|
||||
|
||||
/// The accounts that wait, new or suspended, the longest waiting first.
|
||||
Future<List<OperatedAccount>> waiting();
|
||||
|
||||
/// Approves [account], new or suspended: it signs in once more.
|
||||
Future<void> approve(String account);
|
||||
|
||||
/// Refuses [account], new and waiting: it is deleted with all it has.
|
||||
Future<void> refuse(String account);
|
||||
|
||||
/// The accounts whose handle starts with [start], at most 50.
|
||||
Future<List<OperatedAccount>> accountsStartingWith(String start);
|
||||
|
||||
/// Suspends [account], no operator's: its sessions end, and it waits.
|
||||
Future<void> suspend(String account);
|
||||
|
||||
/// Who operates the instance, by handle.
|
||||
Future<List<OperatedAccount>> operators();
|
||||
|
||||
/// Names the account with [handle] an operator.
|
||||
Future<void> appoint(String handle);
|
||||
|
||||
/// Takes the role back from [account], never from the last operator.
|
||||
Future<void> takeBack(String account);
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_app/src/operator/data/instance_operator_gateway.dart';
|
||||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
|
||||
/// What the instance's operators do there (Halkyon Learn's ADR 0021, D21 to
|
||||
/// D24), as the app's screens use it: the accounts that wait, approved or
|
||||
/// refused, accounts found and suspended, and who operates. Refusals are
|
||||
/// [OperatorFailed]s.
|
||||
class InstanceOperatorRepository(final InstanceOperatorGateway _gateway) {
|
||||
final _changes = StreamController<void>.broadcast();
|
||||
|
||||
/// Every change made through this repository, for what counts the
|
||||
/// accounts that wait to count them anew.
|
||||
Stream<void> get changes => _changes.stream;
|
||||
|
||||
/// How many accounts wait, where the signed-in person operates the
|
||||
/// instance; null where they don't.
|
||||
Future<int?> operating() => _gateway.operating();
|
||||
|
||||
/// The accounts that wait, new or suspended, the longest waiting first.
|
||||
Future<List<OperatedAccount>> waiting() => _gateway.waiting();
|
||||
|
||||
/// Approves [account], new or suspended, once its person confirmed the
|
||||
/// words the operator read out (D33).
|
||||
Future<void> approve(String account) => _changing(_gateway.approve(account));
|
||||
|
||||
/// Refuses [account], new: deleted with all it has.
|
||||
Future<void> refuse(String account) => _changing(_gateway.refuse(account));
|
||||
|
||||
/// The accounts whose handle starts with [start] (an `@` before it
|
||||
/// left out); none for nothing typed.
|
||||
Future<List<OperatedAccount>> accountsStartingWith(String start) async {
|
||||
final typed = start.trim().replaceFirst(RegExp('^@'), '');
|
||||
return typed.isEmpty
|
||||
? const []
|
||||
: await _gateway.accountsStartingWith(typed);
|
||||
}
|
||||
|
||||
/// Suspends [account]: signed out everywhere, it waits.
|
||||
Future<void> suspend(String account) => _changing(_gateway.suspend(account));
|
||||
|
||||
/// Who operates the instance, by handle.
|
||||
Future<List<OperatedAccount>> operators() => _gateway.operators();
|
||||
|
||||
/// Names the account with [handle] (an `@` before it left out) an
|
||||
/// operator; one that waits is approved with it.
|
||||
Future<void> appoint(String handle) => _changing(
|
||||
_gateway.appoint(handle.trim().replaceFirst(RegExp('^@'), '')),
|
||||
);
|
||||
|
||||
/// Takes the role back from [account].
|
||||
Future<void> takeBack(String account) =>
|
||||
_changing(_gateway.takeBack(account));
|
||||
|
||||
Future<void> _changing(Future<void> change) async {
|
||||
await change;
|
||||
if (!_changes.isClosed) {
|
||||
_changes.add(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes [changes]; the owner calls it as it closes.
|
||||
Future<void> dispose() async {
|
||||
// Not awaited: a broadcast stream's close completes in the root zone,
|
||||
// which a widget test's fake clock never reaches.
|
||||
unawaited(_changes.close());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:halkyon_app/src/operator/data/instance_operator_gateway.dart';
|
||||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
import 'package:halkyon_base_client/halkyon_base_client.dart' as protocol;
|
||||
import 'package:serverpod_client/serverpod_client.dart';
|
||||
|
||||
/// [InstanceOperatorGateway] on the module's endpoints of an app's client
|
||||
/// ([_base], `client.modules.base`), as the device's person.
|
||||
class ServerpodInstanceOperatorGateway(final protocol.Caller _base)
|
||||
implements InstanceOperatorGateway {
|
||||
@override
|
||||
Future<int?> operating() => _failing(_base.account.operating);
|
||||
|
||||
@override
|
||||
Future<List<OperatedAccount>> waiting() => _failing(
|
||||
() async => [...(await _base.instanceOperator.waiting()).map(_of)],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> approve(String account) =>
|
||||
_failing(() => _base.instanceOperator.approve(account: _idOf(account)));
|
||||
|
||||
@override
|
||||
Future<void> refuse(String account) =>
|
||||
_failing(() => _base.instanceOperator.refuse(account: _idOf(account)));
|
||||
|
||||
@override
|
||||
Future<List<OperatedAccount>> accountsStartingWith(String start) => _failing(
|
||||
() async => [
|
||||
...(await _base.instanceOperator.accounts(startingWith: start)).map(_of),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> suspend(String account) =>
|
||||
_failing(() => _base.instanceOperator.suspend(account: _idOf(account)));
|
||||
|
||||
@override
|
||||
Future<List<OperatedAccount>> operators() => _failing(
|
||||
() async => [...(await _base.instanceOperator.operators()).map(_of)],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> appoint(String handle) =>
|
||||
_failing(() => _base.instanceOperator.appoint(handle: handle));
|
||||
|
||||
@override
|
||||
Future<void> takeBack(String account) =>
|
||||
_failing(() => _base.instanceOperator.takeBack(account: _idOf(account)));
|
||||
|
||||
static UuidValue _idOf(String account) => UuidValue.withValidation(account);
|
||||
|
||||
static OperatedAccount _of(protocol.AccountOverview overview) =>
|
||||
OperatedAccount(
|
||||
id: overview.account.uuid,
|
||||
handle: overview.handle,
|
||||
since: overview.since,
|
||||
isOperator: overview.isOperator,
|
||||
waitingSince: overview.waitingSince,
|
||||
words: switch (overview.words) {
|
||||
final words? => List.unmodifiable(words),
|
||||
null => null,
|
||||
},
|
||||
suspended: overview.suspended,
|
||||
);
|
||||
}
|
||||
|
||||
/// What [call] gives; the instance's refusals, and failing to reach it, as
|
||||
/// [OperatorFailed].
|
||||
Future<T> _failing<T>(Future<T> Function() call) async {
|
||||
try {
|
||||
return await call();
|
||||
} on protocol.OperatorRefusedException catch (refusal) {
|
||||
throw OperatorFailed(switch (refusal.reason) {
|
||||
protocol.OperatorRefusal.notFound => OperatorProblem.notFound,
|
||||
protocol.OperatorRefusal.isOperator => OperatorProblem.isOperator,
|
||||
protocol.OperatorRefusal.lastOperator => OperatorProblem.lastOperator,
|
||||
});
|
||||
} on ServerpodClientUnauthorized {
|
||||
throw const OperatorFailed(OperatorProblem.notAllowed);
|
||||
} on ServerpodClientForbidden {
|
||||
// A session without the operator's scope.
|
||||
throw const OperatorFailed(OperatorProblem.notAllowed);
|
||||
} on Exception {
|
||||
// No connection, a timeout.
|
||||
throw const OperatorFailed(OperatorProblem.failed);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// An account as the instance's operator sees it (Halkyon Learn's ADR
|
||||
/// 0021, point 3): what the instance knows of it, never its content.
|
||||
final class const OperatedAccount({
|
||||
/// Its ID, which the operator's calls name it by.
|
||||
required final String id,
|
||||
|
||||
/// Its handle; null for an account without one yet.
|
||||
final String? handle,
|
||||
|
||||
/// When it was made.
|
||||
required final DateTime since,
|
||||
final bool isOperator = false,
|
||||
|
||||
/// Since when it waits, new or suspended; null where it doesn't.
|
||||
final DateTime? waitingSince,
|
||||
|
||||
/// The words the operator reads out while it waits, for its person to
|
||||
/// match (D33); null where it doesn't wait.
|
||||
final List<String>? words,
|
||||
|
||||
/// Whether it waits suspended, approved before.
|
||||
final bool suspended = false,
|
||||
}) with Equatable {
|
||||
/// Whether it waits for the operator, new or suspended.
|
||||
bool get waits => waitingSince != null;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
handle,
|
||||
since,
|
||||
isOperator,
|
||||
waitingSince,
|
||||
words,
|
||||
suspended,
|
||||
];
|
||||
}
|
||||
|
||||
/// Why something the operator asked for failed; nothing changed.
|
||||
enum OperatorProblem() {
|
||||
/// No such account, or none that waits as it was to.
|
||||
notFound,
|
||||
|
||||
/// An operator can't be suspended: their role is taken back first.
|
||||
isOperator,
|
||||
|
||||
/// The last operator stays.
|
||||
lastOperator,
|
||||
|
||||
/// The signed-in person is no operator here (any more).
|
||||
notAllowed,
|
||||
|
||||
/// Anything else, such as no connection.
|
||||
failed,
|
||||
}
|
||||
|
||||
/// Something the operator asked for failed, as [problem] says.
|
||||
final class const OperatorFailed(final OperatorProblem problem)
|
||||
with Equatable
|
||||
implements Exception {
|
||||
@override
|
||||
List<Object?> get props => [problem];
|
||||
|
||||
@override
|
||||
String toString() => 'OperatorFailed(${problem.name})';
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||
import 'package:halkyon_app/src/operator/bloc/operating_cubit.dart';
|
||||
import 'package:halkyon_app/src/operator/widget/instance_operator_screen.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// "This instance" for whoever operates it (Halkyon Learn's ADR 0021, D34),
|
||||
/// as tiles for an app's own list: its title, and how many accounts wait,
|
||||
/// which leads to [InstanceOperatorScreen]; nothing for anyone else.
|
||||
/// [OperatingCubit] above it says.
|
||||
class const InstanceOperatorTiles({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waiting = context.watch<OperatingCubit>().state;
|
||||
if (waiting == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Text(
|
||||
l10n.operatorTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.userCheck),
|
||||
title: Text(l10n.operatorAccounts),
|
||||
subtitle: Text(l10n.operatorWaitingCount(waiting)),
|
||||
trailing: waiting > 0 ? CountBadge(count: waiting) : null,
|
||||
onTap: () => showInstanceOperatorScreen(context),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [InstanceOperatorTiles] in a card of an app's settings, with [below] of
|
||||
/// space after it; nothing at all for anyone who doesn't operate the
|
||||
/// instance.
|
||||
class const InstanceOperatorCard({final double below = 0, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (context.watch<OperatingCubit>().state == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: below),
|
||||
child: const SectionCard(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: InstanceOperatorTiles(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [child] (an app's Settings cog) with how many accounts wait for the
|
||||
/// signed-in person, where they operate the instance (D34); as it is
|
||||
/// otherwise. [OperatingCubit] above it says.
|
||||
class const WaitingAccountsBadge({required final Widget child, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waiting = context.select<OperatingCubit, int?>(
|
||||
(cubit) => cubit.state,
|
||||
);
|
||||
if (waiting == null || waiting == 0) {
|
||||
return child;
|
||||
}
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return Semantics(
|
||||
label: HalkyonAppLocalizations.of(context).operatorWaitingCount(waiting),
|
||||
child: Badge.count(
|
||||
count: waiting,
|
||||
backgroundColor: colors.primary,
|
||||
textColor: colors.onPrimary,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/l10n/halkyon_app_localizations.dart';
|
||||
import 'package:halkyon_app/src/app/app_scope.dart';
|
||||
import 'package:halkyon_app/src/operator/bloc/instance_operator_cubit.dart';
|
||||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Opens [InstanceOperatorScreen] above the screen at [context].
|
||||
Future<void> showInstanceOperatorScreen(BuildContext context) =>
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => const InstanceOperatorScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
/// What the instance's operator does there (Halkyon Learn's ADR 0021, D21
|
||||
/// to D24), laid out as an app's settings: the accounts that wait, each
|
||||
/// approved once its person confirmed the words read out to them (D33), or
|
||||
/// refused; who operates, named and taken back; and accounts found by the
|
||||
/// start of their handle, suspended or named operators. Never an account's
|
||||
/// content: only what the instance knows of it.
|
||||
class const InstanceOperatorScreen({super.key}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) =>
|
||||
InstanceOperatorCubit(AppScope.of(context).instanceOperators)..load(),
|
||||
child: const _OperatorView(),
|
||||
);
|
||||
}
|
||||
|
||||
class const _OperatorView() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return TopGradient(
|
||||
color: Brand.primary,
|
||||
background: theme.colorScheme.surfaceContainerLow,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
systemOverlayStyle: TopGradient.overlayStyle(context),
|
||||
title: Text(l10n.operatorTitle),
|
||||
),
|
||||
body: BlocConsumer<InstanceOperatorCubit, InstanceOperatorState>(
|
||||
// What couldn't be read is said in the screen's place.
|
||||
listenWhen: (previous, current) =>
|
||||
current.problem != null &&
|
||||
current.problem != previous.problem &&
|
||||
current.unreadable == null,
|
||||
listener: (context, state) =>
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(operatorProblemText(l10n, state.problem)),
|
||||
),
|
||||
),
|
||||
builder: (context, state) {
|
||||
if (state.loading) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (state.unreadable case final failed?) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
failed.problem == OperatorProblem.failed
|
||||
? l10n.sessionUnreachable
|
||||
: operatorProblemText(l10n, failed),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () =>
|
||||
context.read<InstanceOperatorCubit>().load(),
|
||||
child: Text(l10n.tryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: readableContentMaxWidth,
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(12),
|
||||
children: [
|
||||
_Waiting(state),
|
||||
const SizedBox(height: 12),
|
||||
_Operators(state),
|
||||
const SizedBox(height: 12),
|
||||
_Find(state),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [failed] in the app's words.
|
||||
String operatorProblemText(
|
||||
HalkyonAppLocalizations l10n,
|
||||
OperatorFailed? failed,
|
||||
) => switch (failed?.problem) {
|
||||
OperatorProblem.notFound => l10n.operatorNotFound,
|
||||
OperatorProblem.isOperator => l10n.operatorIsOperatorProblem,
|
||||
OperatorProblem.lastOperator => l10n.operatorLastOperator,
|
||||
OperatorProblem.notAllowed => l10n.operatorNotAllowed,
|
||||
OperatorProblem.failed || null => l10n.operatorFailed,
|
||||
};
|
||||
|
||||
/// What [account] is to the instance, as an icon: the same as in the
|
||||
/// waiting and operators' cards.
|
||||
IconData _iconOf(OperatedAccount account) => switch (account) {
|
||||
OperatedAccount(isOperator: true) => LucideIcons.shieldCheck,
|
||||
OperatedAccount(suspended: true) => LucideIcons.userX,
|
||||
OperatedAccount(waits: true) => LucideIcons.userPlus,
|
||||
_ => LucideIcons.user,
|
||||
};
|
||||
|
||||
/// An account's handle, as people know it.
|
||||
String _nameOf(HalkyonAppLocalizations l10n, OperatedAccount account) =>
|
||||
switch (account.handle) {
|
||||
final handle? => '@$handle',
|
||||
null => l10n.operatorNoHandle,
|
||||
};
|
||||
|
||||
String _day(BuildContext context, DateTime at) => DateFormat.yMMMd(
|
||||
Localizations.localeOf(context).toLanguageTag(),
|
||||
).format(at.toLocal());
|
||||
|
||||
/// A card's title, as the settings' cards have them.
|
||||
class const _Title(final String text, {final int? count})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
|
||||
),
|
||||
if (count case final count? when count > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
CountBadge(count: count),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class const _Waiting(final InstanceOperatorState state)
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Title(l10n.operatorWaitingTitle, count: state.waiting.length),
|
||||
if (state.waiting.isEmpty)
|
||||
ListTile(title: Text(l10n.operatorNobodyWaits))
|
||||
else
|
||||
for (final account in state.waiting)
|
||||
ListTile(
|
||||
leading: Icon(_iconOf(account)),
|
||||
title: Text(_nameOf(l10n, account)),
|
||||
subtitle: Text(
|
||||
account.suspended
|
||||
? l10n.operatorSuspendedSince(
|
||||
_day(context, account.waitingSince!),
|
||||
)
|
||||
: l10n.operatorNewSince(
|
||||
_day(context, account.waitingSince!),
|
||||
),
|
||||
),
|
||||
trailing: state.busy == account.id
|
||||
? const ButtonLoadingIndicator()
|
||||
: const Icon(LucideIcons.chevronRight),
|
||||
onTap: state.busy == null
|
||||
? () => _review(context, account)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The account's words to read out, then approved, or refused after
|
||||
/// asking.
|
||||
Future<void> _review(BuildContext context, OperatedAccount account) async {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final cubit = context.read<InstanceOperatorCubit>();
|
||||
final theme = Theme.of(context);
|
||||
final choice = await showDialog<_Choice>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(_nameOf(l10n, account)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.waitMatchingCode,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final word in account.words ?? const <String>[])
|
||||
Text(word, style: theme.textTheme.titleLarge),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.operatorApproveNote),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!account.suspended)
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(_Choice.refuse),
|
||||
child: Text(l10n.operatorRefuse),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_Choice.approve),
|
||||
child: Text(l10n.operatorApprove),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
switch (choice) {
|
||||
case _Choice.approve:
|
||||
await cubit.approve(account.id);
|
||||
case _Choice.refuse:
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.operatorRefuseTitle,
|
||||
message: l10n.operatorRefuseMessage,
|
||||
confirmLabel: l10n.operatorRefuse,
|
||||
)) {
|
||||
await cubit.refuse(account.id);
|
||||
}
|
||||
case null:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _Choice() {
|
||||
approve,
|
||||
refuse,
|
||||
}
|
||||
|
||||
class const _Operators(final InstanceOperatorState state)
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Title(l10n.operatorOperatorsTitle),
|
||||
for (final operator in state.operators)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.shieldCheck),
|
||||
title: Text(_nameOf(l10n, operator)),
|
||||
trailing: state.busy == operator.id
|
||||
? const ButtonLoadingIndicator()
|
||||
: TextButton(
|
||||
onPressed: state.busy == null
|
||||
? () => _takeBack(context, operator)
|
||||
: null,
|
||||
child: Text(l10n.operatorTakeBack),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.plus),
|
||||
title: Text(l10n.operatorAppoint),
|
||||
onTap: state.busy == null ? () => _appoint(context) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Takes the role back from [operator], after asking.
|
||||
Future<void> _takeBack(BuildContext context, OperatedAccount operator) async {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final cubit = context.read<InstanceOperatorCubit>();
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.operatorTakeBackTitle,
|
||||
message: l10n.operatorTakeBackMessage,
|
||||
confirmLabel: l10n.operatorTakeBack,
|
||||
)) {
|
||||
await cubit.takeBack(operator.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Names the account of the handle typed an operator; nothing typed,
|
||||
/// nothing asked.
|
||||
Future<void> _appoint(BuildContext context) async {
|
||||
final cubit = context.read<InstanceOperatorCubit>();
|
||||
final handle = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const _AppointDialog(),
|
||||
);
|
||||
if (handle != null && handle.trim().isNotEmpty) {
|
||||
await cubit.appoint(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the handle of the account to name an operator.
|
||||
class const _AppointDialog() extends StatefulWidget {
|
||||
@override
|
||||
State<_AppointDialog> createState() => _AppointDialogState();
|
||||
}
|
||||
|
||||
class _AppointDialogState() extends State<_AppointDialog> {
|
||||
final _handle = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_handle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return AlertDialog(
|
||||
title: Text(l10n.operatorAppoint),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.operatorAppointMessage),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _handle,
|
||||
autofocus: true,
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.operatorHandle,
|
||||
prefixText: '@',
|
||||
),
|
||||
onSubmitted: (handle) => Navigator.of(context).pop(handle),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(_handle.text),
|
||||
child: Text(l10n.operatorAppointConfirm),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class const _Find(final InstanceOperatorState state) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
return SectionCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Title(l10n.operatorFindTitle),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
||||
child: TextField(
|
||||
autocorrect: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.operatorHandle,
|
||||
prefixText: '@',
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
),
|
||||
onChanged: (query) =>
|
||||
context.read<InstanceOperatorCubit>().find(query),
|
||||
),
|
||||
),
|
||||
if (state.query.trim().isNotEmpty && state.found.isEmpty)
|
||||
ListTile(title: Text(l10n.operatorFoundNone)),
|
||||
for (final account in state.found)
|
||||
ListTile(
|
||||
// As the other cards' rows, so every name lines up.
|
||||
leading: Icon(_iconOf(account)),
|
||||
title: Text(_nameOf(l10n, account)),
|
||||
subtitle: Text(
|
||||
[
|
||||
l10n.operatorMadeOn(_day(context, account.since)),
|
||||
if (account.isOperator) l10n.operatorIsOperator,
|
||||
if (account.suspended)
|
||||
l10n.operatorIsSuspended
|
||||
else if (account.waits)
|
||||
l10n.operatorIsWaiting,
|
||||
].join(' · '),
|
||||
),
|
||||
trailing: state.busy == account.id
|
||||
? const ButtonLoadingIndicator()
|
||||
: account.isOperator
|
||||
? null
|
||||
: PopupMenuButton<_Action>(
|
||||
icon: const Icon(LucideIcons.ellipsisVertical),
|
||||
enabled: state.busy == null,
|
||||
onSelected: (action) => _act(context, account, action),
|
||||
itemBuilder: (context) => [
|
||||
if (!account.waits)
|
||||
PopupMenuItem(
|
||||
value: _Action.suspend,
|
||||
child: Text(l10n.operatorSuspend),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: _Action.appoint,
|
||||
child: Text(l10n.operatorMakeOperator),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _act(
|
||||
BuildContext context,
|
||||
OperatedAccount account,
|
||||
_Action action,
|
||||
) async {
|
||||
final l10n = HalkyonAppLocalizations.of(context);
|
||||
final cubit = context.read<InstanceOperatorCubit>();
|
||||
switch (action) {
|
||||
case _Action.suspend:
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.operatorSuspendTitle,
|
||||
message: l10n.operatorSuspendMessage,
|
||||
confirmLabel: l10n.operatorSuspend,
|
||||
)) {
|
||||
await cubit.suspend(account.id);
|
||||
}
|
||||
case _Action.appoint:
|
||||
if (account.handle case final handle?) {
|
||||
await cubit.appoint(handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _Action() {
|
||||
suspend,
|
||||
appoint,
|
||||
}
|
||||
|
|
@ -128,8 +128,8 @@ enum PasswordProblem() {
|
|||
/// The instance takes no new accounts (Halkyon Learn's ADR 0021, D19).
|
||||
signUpsClosed,
|
||||
|
||||
/// As many new accounts wait for the operator's approval as may: try
|
||||
/// again later (D19).
|
||||
/// The most new accounts the instance lets wait for the operator's
|
||||
/// approval already wait: try again later (D19).
|
||||
tooManyWaiting,
|
||||
|
||||
/// Anything else, such as no connection.
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ enum SimpleLoginProblem() {
|
|||
/// The instance takes no new accounts (Halkyon Learn's ADR 0021, D19).
|
||||
signUpsClosed,
|
||||
|
||||
/// As many new accounts wait for the operator's approval as may: try
|
||||
/// again later (D19).
|
||||
/// The most new accounts the instance lets wait for the operator's
|
||||
/// approval already wait: try again later (D19).
|
||||
tooManyWaiting,
|
||||
|
||||
/// No connection, the instance failed, or the browser's way back did.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
import 'package:halkyon_app/src/operator/data/instance_operator_gateway.dart';
|
||||
import 'package:halkyon_app/src/operator/model/operated_account.dart';
|
||||
|
||||
/// An [InstanceOperatorGateway] without a server, as the instance's
|
||||
/// register behaves in what an operator sees (Halkyon Learn's ADR 0021):
|
||||
/// the device's person operates it where [operates], and its [accounts]
|
||||
/// are by ID. [failing] makes every call fail.
|
||||
class FakeInstanceOperatorGateway() implements InstanceOperatorGateway {
|
||||
/// Whether the device's person operates the instance.
|
||||
bool operates = false;
|
||||
|
||||
/// The instance's accounts, by ID.
|
||||
final accounts = <String, OperatedAccount>{};
|
||||
|
||||
/// The calls made, by name and what they named, in order.
|
||||
final calls = <String>[];
|
||||
|
||||
/// Thrown by every call while set.
|
||||
OperatorFailed? failing;
|
||||
|
||||
/// Adds an account as the instance has it.
|
||||
void add(OperatedAccount account) => accounts[account.id] = account;
|
||||
|
||||
Future<T> _call<T>(String name, T Function() answer) async {
|
||||
calls.add(name);
|
||||
if (failing case final failure?) {
|
||||
throw failure;
|
||||
}
|
||||
if (!operates && name != 'operating') {
|
||||
throw const OperatorFailed(OperatorProblem.notAllowed);
|
||||
}
|
||||
return answer();
|
||||
}
|
||||
|
||||
OperatedAccount _account(String id) =>
|
||||
accounts[id] ?? (throw const OperatorFailed(OperatorProblem.notFound));
|
||||
|
||||
Iterable<OperatedAccount> get _waiting =>
|
||||
accounts.values.where((account) => account.waits);
|
||||
|
||||
@override
|
||||
Future<int?> operating() =>
|
||||
_call('operating', () => operates ? _waiting.length : null);
|
||||
|
||||
@override
|
||||
Future<List<OperatedAccount>> waiting() => _call(
|
||||
'waiting',
|
||||
() =>
|
||||
[..._waiting]
|
||||
..sort((a, b) => a.waitingSince!.compareTo(b.waitingSince!)),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> approve(String account) => _call('approve $account', () {
|
||||
final waiting = _account(account);
|
||||
if (!waiting.waits) {
|
||||
throw const OperatorFailed(OperatorProblem.notFound);
|
||||
}
|
||||
accounts[account] = _copy(waiting, waits: false);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> refuse(String account) => _call('refuse $account', () {
|
||||
final waiting = _account(account);
|
||||
if (!waiting.waits || waiting.suspended) {
|
||||
throw const OperatorFailed(OperatorProblem.notFound);
|
||||
}
|
||||
accounts.remove(account);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<OperatedAccount>> accountsStartingWith(String start) => _call(
|
||||
'accounts $start',
|
||||
() =>
|
||||
[
|
||||
for (final account in accounts.values)
|
||||
if (account.handle?.toLowerCase().startsWith(start.toLowerCase()) ??
|
||||
false)
|
||||
account,
|
||||
]..sort(
|
||||
(a, b) => a.handle!.toLowerCase().compareTo(b.handle!.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> suspend(String account) => _call('suspend $account', () {
|
||||
final found = _account(account);
|
||||
if (found.isOperator) {
|
||||
throw const OperatorFailed(OperatorProblem.isOperator);
|
||||
}
|
||||
if (found.waits) {
|
||||
throw const OperatorFailed(OperatorProblem.notFound);
|
||||
}
|
||||
accounts[account] = OperatedAccount(
|
||||
id: found.id,
|
||||
handle: found.handle,
|
||||
since: found.since,
|
||||
waitingSince: DateTime.utc(2026, 10, 25, 12),
|
||||
words: const ['zoo', 'zone', 'youth', 'young'],
|
||||
suspended: true,
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<OperatedAccount>> operators() => _call(
|
||||
'operators',
|
||||
() => [
|
||||
for (final account in accounts.values)
|
||||
if (account.isOperator) account,
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> appoint(String handle) => _call('appoint $handle', () {
|
||||
final found = accounts.values
|
||||
.where(
|
||||
(account) => account.handle?.toLowerCase() == handle.toLowerCase(),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (found == null) {
|
||||
throw const OperatorFailed(OperatorProblem.notFound);
|
||||
}
|
||||
accounts[found.id] = _copy(found, waits: false, isOperator: true);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> takeBack(String account) => _call('takeBack $account', () {
|
||||
final found = _account(account);
|
||||
if (!found.isOperator) {
|
||||
throw const OperatorFailed(OperatorProblem.notFound);
|
||||
}
|
||||
if (accounts.values.where((each) => each.isOperator).length == 1) {
|
||||
throw const OperatorFailed(OperatorProblem.lastOperator);
|
||||
}
|
||||
accounts[account] = _copy(found, isOperator: false);
|
||||
});
|
||||
|
||||
static OperatedAccount _copy(
|
||||
OperatedAccount account, {
|
||||
bool? waits,
|
||||
bool? isOperator,
|
||||
}) {
|
||||
final stillWaits = waits ?? account.waits;
|
||||
return OperatedAccount(
|
||||
id: account.id,
|
||||
handle: account.handle,
|
||||
since: account.since,
|
||||
isOperator: isOperator ?? account.isOperator,
|
||||
waitingSince: stillWaits ? account.waitingSince : null,
|
||||
words: stillWaits ? account.words : null,
|
||||
suspended: stillWaits && account.suspended,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,10 +5,12 @@ import 'package:halkyon_app/src/app/app_scope.dart';
|
|||
import 'package:halkyon_app/src/auth/data/auth_gateway.dart';
|
||||
import 'package:halkyon_app/src/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_app/src/auth/model/auth_status.dart';
|
||||
import 'package:halkyon_app/src/operator/data/instance_operator_repository.dart';
|
||||
import 'package:halkyon_app/src/password/data/password_repository.dart';
|
||||
import 'package:halkyon_app/src/simplelogin/data/simplelogin_repository.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_account_crypto.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_auth_gateway.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_instance_operator_gateway.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_oidc_browser.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_password_gateway.dart';
|
||||
import 'package:halkyon_app/src/testing/fake_simplelogin_gateway.dart';
|
||||
|
|
@ -139,6 +141,14 @@ class TestAppDependencies({
|
|||
platform: TargetPlatform.android,
|
||||
);
|
||||
|
||||
/// What an operator sees of the instance, without a server: the device's
|
||||
/// person operates it only where a test says so (`operates`).
|
||||
final operatorGateway = FakeInstanceOperatorGateway();
|
||||
|
||||
@override
|
||||
late final InstanceOperatorRepository instanceOperators =
|
||||
InstanceOperatorRepository(operatorGateway);
|
||||
|
||||
@override
|
||||
final MemoryFileSaver fileSaver = MemoryFileSaver();
|
||||
|
||||
|
|
@ -180,6 +190,7 @@ class TestAppDependencies({
|
|||
disposed = true;
|
||||
await passwords.dispose();
|
||||
await simpleLogin.dispose();
|
||||
await instanceOperators.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue