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:
Tizian 「ティツィアーン」 2026-10-25 15:55:28 +01:00
commit 31b9c17850
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 2999 additions and 28 deletions

View file

@ -96,6 +96,13 @@ class _PersonAppState() extends State<PersonApp> {
PersonDependencies.of(context).discussions,
)..add(const InboxCountEventRefreshed()),
),
// How many accounts wait, for whoever operates the
// instance: Settings' card and the badge on its cogs.
BlocProvider(
create: (context) => OperatingCubit(
Dependencies.of(context).instanceOperators,
),
),
// The Pomodoro timer goes on across every screen.
BlocProvider(
create: (context) => PomodoroBloc(

View file

@ -117,6 +117,9 @@ class Dependencies({
/// 0020).
@override required final SimpleLoginRepository simpleLogin,
/// What the instance's operators do there (ADR 0021, D21 to D24).
@override required final InstanceOperatorRepository instanceOperators,
/// Where the source of what the instance runs is, as it says
/// ([sourceCodeUrl]).
required final Future<String> Function() _sourceCodeUrl,
@ -255,6 +258,9 @@ class Dependencies({
callbackScheme: 'app.halkyon.learn',
loopbackPort: 43823,
);
final instanceOperators = InstanceOperatorRepository(
ServerpodInstanceOperatorGateway(client.modules.base),
);
final folders = PersonFolders(device.storage.peopleOf(instance));
// One person at a time: before the next person's data opens, the previous
// person's database has closed, so their folder can go. If it doesn't
@ -271,6 +277,7 @@ class Dependencies({
auth: AuthRepository(authGateway, addresses),
passwords: passwords,
simpleLogin: simpleLogin,
instanceOperators: instanceOperators,
sourceCodeUrl: () async => (await client.instance.info()).sourceCodeUrl,
dictionaries: DictionaryRepository(
ServerpodDictionaryGateway(client),
@ -325,6 +332,7 @@ class Dependencies({
await deviceDictionaries.dispose();
await passwords.dispose();
await simpleLogin.dispose();
await instanceOperators.dispose();
await authGateway.dispose();
client.close();
},

View file

@ -36,8 +36,10 @@ class const ProfileScreen({super.key}) extends StatelessWidget {
title: Text(l10n.profileTitle),
actions: [
IconButton(
icon: const SyncTroubleBadge(
child: Icon(LucideIcons.settings),
icon: const WaitingAccountsBadge(
child: SyncTroubleBadge(
child: Icon(LucideIcons.settings),
),
),
tooltip: l10n.profileSettingsTooltip,
onPressed: () => context.push(RoutePaths.settings),

View file

@ -141,8 +141,10 @@ class _HomeState() extends State<_Home> {
today: today,
),
IconButton(
icon: const SyncTroubleBadge(
child: Icon(LucideIcons.settings),
icon: const WaitingAccountsBadge(
child: SyncTroubleBadge(
child: Icon(LucideIcons.settings),
),
),
tooltip: l10n.settingsTitle,
onPressed: () => context.push(RoutePaths.settings),

View file

@ -52,6 +52,10 @@ class const SettingsScreen({super.key}) extends StatelessWidget {
children: [
const SyncStatusCard(),
const SizedBox(height: 12),
// For whoever operates the instance alone (ADR 0021,
// D34): what the cogs' count leads to, so it comes first
// after the Sync card their dot leads to.
const InstanceOperatorCard(below: 12),
const DeviceSettingsCard(),
const SizedBox(height: 12),
const CitationSettingsCard(),