feat(learn): a Viewer opens a shared studyset's material read-only

Every route of a studyset says what the person may do there
(StudysetAccess, set in _inStudyset): whether it is shared with people,
and whether they only view and study it (Halkyon's ADR 0044, D35, D36).

A Viewer opens cards and notes on the same screens, read-only: the rich
fields show their content (LearnContentEditor shows LearnContentView),
the composer's plain fields are read-only and its changes go nowhere,
the note screen reads as a conflict leaves it, each with the line that
the studyset is theirs to view and study. The lists, the card browser
and a study session offer them nothing that writes: no new card, import,
edit, move, rename, reading deadline, reorder or delete. Their tags,
progress and reading stay theirs to change, being their own rows.

Burying and reporting a card are written on its row, which every member
shares, so they are hidden in shared studysets for everyone until they
become each person's own rows (D36).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 17:21:28 +01:00
commit 8dd3ec0522
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 503 additions and 194 deletions

View file

@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.
import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart'; import 'package:halkyon_learn_flutter/src/core/router/app_shell.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart'; import 'package:halkyon_learn_flutter/src/core/router/tab_visibility.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/widget/profile_screen.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
@ -427,7 +428,13 @@ Widget _inStudyset(
color: color:
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ?? PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
Brand.primary, Brand.primary,
child: builder(studyset, all), // What the person may do there, shared with them as a Viewer or
// not (Halkyon's ADR 0044, D30, D35, D36).
child: StudysetAccess(
shared: studyset.isShared,
viewOnly: studyset.viewOnly,
child: builder(studyset, all),
),
), ),
), ),
), ),

View file

@ -3,11 +3,13 @@ import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/core_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart'; import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// halkyon_editor's [RichContentEditor] with Learn's blocks ([blocksOf]) and, /// halkyon_editor's [RichContentEditor] with Learn's blocks ([blocksOf]) and,
/// with [grammar], spelling and grammar checked as the text is written, in /// with [grammar], spelling and grammar checked as the text is written, in
/// that language ([GrammarHost], ADR 0024). /// that language ([GrammarHost], ADR 0024). In a studyset the person only
/// views ([StudysetAccess]), the content shown, not editable.
class const LearnContentEditor({ class const LearnContentEditor({
/// The content the editor opens with; later changes to it are ignored. /// The content the editor opens with; later changes to it are ignored.
required final RichContent initialContent, required final RichContent initialContent,
@ -32,16 +34,23 @@ class const LearnContentEditor({
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
Widget build(BuildContext context) => RichContentEditor( Widget build(BuildContext context) => StudysetAccess.of(context).viewOnly
initialContent: initialContent, // A Viewer reads what the editor would hold, unchanged (Halkyon's ADR
onChanged: onChanged, // 0044, D35); a long text scrolls where the editor would have.
autofocus: autofocus, ? SingleChildScrollView(
focusNode: focusNode, padding: padding,
formatBar: formatBar, child: LearnContentView(content: initialContent),
padding: padding, )
blocks: blocksOf(context), : RichContentEditor(
wrapEditor: grammar == null ? null : _checked, initialContent: initialContent,
); onChanged: onChanged,
autofocus: autofocus,
focusNode: focusNode,
formatBar: formatBar,
padding: padding,
blocks: blocksOf(context),
wrapEditor: grammar == null ? null : _checked,
);
Widget _checked( Widget _checked(
BuildContext context, BuildContext context,

View file

@ -0,0 +1,67 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// What the person may do in the studyset whose screens are below
/// (Halkyon's ADR 0044, D30, D35, D36), which every route of a studyset
/// sets: whether it is [shared] with people, and whether the person only
/// views and studies it there, a Viewer ([viewOnly]). Screens and lists
/// offer a Viewer nothing that writes, and nobody, in a shared studyset,
/// what would be written on rows every member shares for one person alone
/// (burying, reporting a card).
class const StudysetAccess({
required final bool shared,
required final bool viewOnly,
required super.child,
super.key,
}) extends InheritedWidget {
/// The access above [context]; a studyset of the person's own where none
/// is set.
static ({bool shared, bool viewOnly}) of(BuildContext context) {
final access = context.dependOnInheritedWidgetOfExactType<StudysetAccess>();
return (
shared: access?.shared ?? false,
viewOnly: access?.viewOnly ?? false,
);
}
/// Whether the person only views the studyset above [context], read
/// without depending on it: for callbacks, outside a build.
static bool viewOnlyNow(BuildContext context) =>
context.getInheritedWidgetOfExactType<StudysetAccess>()?.viewOnly ??
false;
@override
bool updateShouldNotify(StudysetAccess oldWidget) =>
shared != oldWidget.shared || viewOnly != oldWidget.viewOnly;
}
/// The line saying the studyset is the person's to view and study, not to
/// change, above what a Viewer opens (Halkyon's ADR 0044, D35).
class const ViewOnlyNote({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Row(
children: [
Icon(
LucideIcons.eye,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).studysetViewOnly,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
);
}
}

View file

@ -13,6 +13,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart'; import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_browser_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
@ -353,6 +354,8 @@ class _BrowserState() extends State<_Browser> {
); );
void study() => context.push(RoutePaths.study(widget.studyset.id)); void study() => context.push(RoutePaths.study(widget.studyset.id));
void write() => context.push(RoutePaths.newCard(widget.studyset.id)); void write() => context.push(RoutePaths.newCard(widget.studyset.id));
// A Viewer adds, moves and deletes nothing (Halkyon's ADR 0044, D35).
final viewOnly = StudysetAccess.of(context).viewOnly;
// Cards moved show where they go at once; a move that fails puts them // Cards moved show where they go at once; a move that fails puts them
// back, and says so in place of the message that they moved. // back, and says so in place of the message that they moved.
return BlocListener<CardBrowserBloc, CardBrowserState>( return BlocListener<CardBrowserBloc, CardBrowserState>(
@ -370,8 +373,9 @@ class _BrowserState() extends State<_Browser> {
child: AppLoadingIndicator(), child: AppLoadingIndicator(),
), ),
CardBrowserState(cards: []) => _Empty( CardBrowserState(cards: []) => _Empty(
onCreate: () => onCreate: viewOnly
context.push(RoutePaths.newCard(widget.studyset.id)), ? null
: () => context.push(RoutePaths.newCard(widget.studyset.id)),
), ),
_ => Column( _ => Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -486,11 +490,12 @@ class _BrowserState() extends State<_Browser> {
label: Text(l10n.cardBrowserStudy), label: Text(l10n.cardBrowserStudy),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
TextButton.icon( if (!viewOnly)
onPressed: write, TextButton.icon(
icon: const Icon(LucideIcons.squarePen, size: 18), onPressed: write,
label: Text(l10n.cardBrowserEditor), icon: const Icon(LucideIcons.squarePen, size: 18),
), label: Text(l10n.cardBrowserEditor),
),
] else ...[ ] else ...[
IconButton.filled( IconButton.filled(
style: filledIconButtonStyle(context), style: filledIconButtonStyle(context),
@ -498,11 +503,12 @@ class _BrowserState() extends State<_Browser> {
icon: const Icon(LucideIcons.play), icon: const Icon(LucideIcons.play),
tooltip: l10n.cardBrowserStudy, tooltip: l10n.cardBrowserStudy,
), ),
IconButton( if (!viewOnly)
onPressed: write, IconButton(
icon: const Icon(LucideIcons.squarePen), onPressed: write,
tooltip: l10n.cardBrowserEditor, icon: const Icon(LucideIcons.squarePen),
), tooltip: l10n.cardBrowserEditor,
),
], ],
PopupMenuButton<_Menu>( PopupMenuButton<_Menu>(
icon: const Icon(LucideIcons.ellipsisVertical), icon: const Icon(LucideIcons.ellipsisVertical),
@ -512,15 +518,16 @@ class _BrowserState() extends State<_Browser> {
_Menu.tags => unawaited(showManageTagsSheet(context)), _Menu.tags => unawaited(showManageTagsSheet(context)),
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
PopupMenuItem( if (!viewOnly)
value: _Menu.import, PopupMenuItem(
child: ListTile( value: _Menu.import,
dense: true, child: ListTile(
contentPadding: EdgeInsets.zero, dense: true,
leading: const Icon(LucideIcons.fileInput), contentPadding: EdgeInsets.zero,
title: Text(l10n.cardBrowserImport), leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
),
), ),
),
PopupMenuItem( PopupMenuItem(
value: _Menu.tags, value: _Menu.tags,
child: ListTile( child: ListTile(
@ -549,12 +556,15 @@ class _BrowserState() extends State<_Browser> {
_Actions _actions(CardBrowserState state) { _Actions _actions(CardBrowserState state) {
final some = state.selected.isNotEmpty; final some = state.selected.isNotEmpty;
// Tags and progress are the person's own; the cards a Viewer's to
// view (Halkyon's ADR 0044, D35).
final writes = some && !StudysetAccess.of(context).viewOnly;
return ( return (
move: some ? () => _move(state) : null, move: writes ? () => _move(state) : null,
addTags: some ? () => _addTags(state) : null, addTags: some ? () => _addTags(state) : null,
removeTags: some ? () => _removeTags(state) : null, removeTags: some ? () => _removeTags(state) : null,
reset: some ? () => _resetProgress(state) : null, reset: some ? () => _resetProgress(state) : null,
delete: some ? () => _delete(state) : null, delete: writes ? () => _delete(state) : null,
done: () => _bloc.add(const CardBrowserEventSelectingToggled()), done: () => _bloc.add(const CardBrowserEventSelectingToggled()),
); );
} }
@ -1693,7 +1703,7 @@ class const _BarButton({
} }
/// A studyset without cards: none to browse, and a way to make some. /// A studyset without cards: none to browse, and a way to make some.
class const _Empty({required final VoidCallback onCreate}) class const _Empty({required final VoidCallback? onCreate})
extends StatelessWidget { extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -1715,12 +1725,14 @@ class const _Empty({required final VoidCallback onCreate})
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 16), if (onCreate != null) ...[
FilledButton.icon( const SizedBox(height: 16),
onPressed: onCreate, FilledButton.icon(
icon: const Icon(LucideIcons.plus, size: 18), onPressed: onCreate,
label: Text(l10n.cardBrowserCreate), icon: const Icon(LucideIcons.plus, size: 18),
), label: Text(l10n.cardBrowserCreate),
),
],
], ],
), ),
), ),

View file

@ -1,5 +1,6 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart'; import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/report_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart'; import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
@ -49,6 +50,10 @@ class const CardTile({
final report = card.content.report; final report = card.content.report;
final hasMeta = tags.isNotEmpty; final hasMeta = tags.isNotEmpty;
final hasBadges = report != null || buried; final hasBadges = report != null || buried;
// A Viewer changes nothing; burying and reporting stay out of shared
// studysets, as every member shares the card's row (Halkyon's ADR
// 0044, D35, D36).
final access = StudysetAccess.of(context);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Material( child: Material(
@ -131,54 +136,58 @@ class const CardTile({
), ),
MenuAnchor( MenuAnchor(
menuChildren: [ menuChildren: [
MenuItemButton( if (!access.viewOnly)
leadingIcon: const Icon(
LucideIcons.pencil,
size: 18,
),
onPressed: onOpen,
child: Text(l10n.cardTileEdit),
),
if (report == null)
MenuItemButton( MenuItemButton(
leadingIcon: const Icon( leadingIcon: const Icon(
LucideIcons.flag, LucideIcons.pencil,
size: 18, size: 18,
), ),
onPressed: onReport, onPressed: onOpen,
child: Text(l10n.cardReportTitle), child: Text(l10n.cardTileEdit),
) ),
else if (!access.shared)
if (report == null)
MenuItemButton(
leadingIcon: const Icon(
LucideIcons.flag,
size: 18,
),
onPressed: onReport,
child: Text(l10n.cardReportTitle),
)
else
MenuItemButton(
leadingIcon: const Icon(
LucideIcons.flagOff,
size: 18,
),
onPressed: onResolve,
child: Text(l10n.cardResolveReport),
),
if (!access.shared)
MenuItemButton( MenuItemButton(
leadingIcon: const Icon( leadingIcon: Icon(
LucideIcons.flagOff, buried ? LucideIcons.sunrise : LucideIcons.moon,
size: 18, size: 18,
), ),
onPressed: onResolve, onPressed: onBuryToggle,
child: Text(l10n.cardResolveReport), child: Text(
buried ? l10n.cardUnbury : l10n.studyBuryCard,
),
), ),
MenuItemButton( if (!access.viewOnly)
leadingIcon: Icon( MenuItemButton(
buried ? LucideIcons.sunrise : LucideIcons.moon, leadingIcon: Icon(
size: 18, LucideIcons.trash2,
size: 18,
color: theme.colorScheme.error,
),
onPressed: onDelete,
child: Text(
l10n.cardTileDelete,
style: TextStyle(color: theme.colorScheme.error),
),
), ),
onPressed: onBuryToggle,
child: Text(
buried ? l10n.cardUnbury : l10n.studyBuryCard,
),
),
MenuItemButton(
leadingIcon: Icon(
LucideIcons.trash2,
size: 18,
color: theme.colorScheme.error,
),
onPressed: onDelete,
child: Text(
l10n.cardTileDelete,
style: TextStyle(color: theme.colorScheme.error),
),
),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.info, size: 18), leadingIcon: const Icon(LucideIcons.info, size: 18),
onPressed: onInfo, onPressed: onInfo,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_draft_editor.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/card_stack_tile.dart';
@ -246,7 +247,10 @@ class _CardComposerScreenState() extends State<CardComposerScreen> {
state: state, state: state,
selected: _selected, selected: _selected,
expanded: expanded, expanded: expanded,
onAdd: _addDraft, // A Viewer adds none (Halkyon's ADR 0044, D35).
onAdd: StudysetAccess.of(context).viewOnly
? null
: _addDraft,
onSelect: _select, onSelect: _select,
onToggle: () => onToggle: () =>
setState(() => _stackExpanded = !expanded), setState(() => _stackExpanded = !expanded),
@ -307,7 +311,9 @@ class const _Stack({
required final CardComposerState state, required final CardComposerState state,
required final String? selected, required final String? selected,
required final bool expanded, required final bool expanded,
required final VoidCallback onAdd,
/// Adds a new card; null for a Viewer, who adds none.
required final VoidCallback? onAdd,
required final ValueChanged<String> onSelect, required final ValueChanged<String> onSelect,
required final VoidCallback onToggle, required final VoidCallback onToggle,
}) extends StatelessWidget { }) extends StatelessWidget {
@ -324,21 +330,23 @@ class const _Stack({
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (expanded) if (onAdd != null) ...[
FilledButton.tonalIcon( if (expanded)
onPressed: onAdd, FilledButton.tonalIcon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.composerNewFlashcard),
)
else
Center(
child: IconButton.filledTonal(
tooltip: l10n.composerNewFlashcard,
onPressed: onAdd, onPressed: onAdd,
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
label: Text(l10n.composerNewFlashcard),
)
else
Center(
child: IconButton.filledTonal(
tooltip: l10n.composerNewFlashcard,
onPressed: onAdd,
icon: const Icon(LucideIcons.plus),
),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), ],
Expanded( Expanded(
child: ListView.separated( child: ListView.separated(
itemCount: items.length, itemCount: items.length,

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart'; import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/composer/answers_editor.dart';
@ -163,6 +164,10 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
} }
void _change(CardContent content) { void _change(CardContent content) {
// A Viewer's changes go nowhere (Halkyon's ADR 0044, D35).
if (StudysetAccess.viewOnlyNow(context)) {
return;
}
_sent = content; _sent = content;
context.read<CardComposerBloc>().add( context.read<CardComposerBloc>().add(
CardComposerEventChanged(widget.draft.id, content), CardComposerEventChanged(widget.draft.id, content),
@ -304,6 +309,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (StudysetAccess.of(context).viewOnly) const ViewOnlyNote(),
Row( Row(
children: [ children: [
TagsPill( TagsPill(
@ -332,18 +338,19 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
_Actions( if (!StudysetAccess.of(context).viewOnly)
onAddHint: content.hints.length < CardContent.maxHints _Actions(
? _addHint onAddHint: content.hints.length < CardContent.maxHints
: null, ? _addHint
onAddExplanation: content.explanation == null : null,
? _addExplanation onAddExplanation: content.explanation == null
: null, ? _addExplanation
onDuplicate: widget.onDuplicate, : null,
onClear: () => _send(CardComposerEventCleared(draft.id)), onDuplicate: widget.onDuplicate,
onDelete: widget.onDelete, onClear: () => _send(CardComposerEventCleared(draft.id)),
onSuggest: widget.onSuggest, onDelete: widget.onDelete,
), onSuggest: widget.onSuggest,
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View file

@ -1,6 +1,7 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_readings.dart'; import 'package:halkyon_learn_flutter/src/feature/kanji/widget/character_readings.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -80,6 +81,7 @@ class _CharacterFieldsState() extends State<CharacterFields> {
Text(label, style: labelStyle), Text(label, style: labelStyle),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: controller, controller: controller,
@ -106,6 +108,7 @@ class _CharacterFieldsState() extends State<CharacterFields> {
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: meaning, controller: meaning,

View file

@ -1,4 +1,5 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -99,6 +100,7 @@ class _MatchingFieldsState() extends State<MatchingFields> {
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: target, controller: target,
@ -154,6 +156,7 @@ class _MatchingFieldsState() extends State<MatchingFields> {
Expanded( Expanded(
flex: 2, flex: 2,
child: TextField( child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: item, controller: item,

View file

@ -1,6 +1,7 @@
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/jmdict_tags.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/jmdict_tags.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
@ -69,6 +70,7 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
Text(l10n.vocabularyReading, style: labelStyle), Text(l10n.vocabularyReading, style: labelStyle),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: _reading, controller: _reading,
@ -103,6 +105,7 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: meaning, controller: meaning,
@ -237,6 +240,7 @@ class _SenseFieldsState() extends State<_SenseFields> {
children: [ children: [
Expanded( Expanded(
child: TextField( child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase, maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter, buildCounter: nearLimitCounter,
controller: gloss, controller: gloss,

View file

@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_info.dart';
@ -166,24 +167,29 @@ class const _PathTile({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error; final error = Theme.of(context).colorScheme.error;
// A Viewer changes nothing in it (Halkyon's ADR 0044, D35); its
// progress is the person's own.
final viewOnly = StudysetAccess.of(context).viewOnly;
return MenuAnchor( return MenuAnchor(
menuChildren: [ menuChildren: [
MenuItemButton( if (!viewOnly)
leadingIcon: const Icon(LucideIcons.pencil), MenuItemButton(
onPressed: () => leadingIcon: const Icon(LucideIcons.pencil),
context.push(RoutePaths.editPath(studysetId, path.id)), onPressed: () =>
child: Text(l10n.pathEdit), context.push(RoutePaths.editPath(studysetId, path.id)),
), child: Text(l10n.pathEdit),
),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.rotateCcw), leadingIcon: const Icon(LucideIcons.rotateCcw),
onPressed: () => _reset(context), onPressed: () => _reset(context),
child: Text(l10n.pathResetProgress), child: Text(l10n.pathResetProgress),
), ),
MenuItemButton( if (!viewOnly)
leadingIcon: Icon(LucideIcons.trash2, color: error), MenuItemButton(
onPressed: () => _delete(context), leadingIcon: Icon(LucideIcons.trash2, color: error),
child: Text(l10n.pathDelete, style: TextStyle(color: error)), onPressed: () => _delete(context),
), child: Text(l10n.pathDelete, style: TextStyle(color: error)),
),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.info), leadingIcon: const Icon(LucideIcons.info),
onPressed: () => unawaited( onPressed: () => unawaited(

View file

@ -7,6 +7,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart'; import 'package:halkyon_learn_flutter/src/core/editor/save_status.dart';
import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart'; import 'package:halkyon_learn_flutter/src/core/grammar/grammar_host.dart';
import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart'; import 'package:halkyon_learn_flutter/src/core/widget/conflict_versions.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/widget/card_proposals_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/widget/card_proposals_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart'; import 'package:halkyon_learn_flutter/src/feature/feedback/widget/feedback_actions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_editor_bloc.dart';
@ -138,7 +139,11 @@ class const _Note({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final readOnly = state.unresolved.isNotEmpty; final conflicted = state.unresolved.isNotEmpty;
// A Viewer reads it as a conflict leaves it, unchanged (Halkyon's ADR
// 0044, D35).
final viewOnly = StudysetAccess.of(context).viewOnly;
final readOnly = conflicted || viewOnly;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Align( child: Align(
@ -150,7 +155,8 @@ class const _Note({
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (readOnly) _Conflicts(state: state), if (conflicted) _Conflicts(state: state),
if (viewOnly) const ViewOnlyNote(),
Expanded( Expanded(
child: SectionCard( child: SectionCard(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View file

@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/note/bloc/note_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/widget/edited_ago.dart'; import 'package:halkyon_learn_flutter/src/feature/note/widget/edited_ago.dart';
@ -115,24 +116,29 @@ class const _NoteTile({
final title = note.content.title; final title = note.content.title;
final editedAt = note.editedAt; final editedAt = note.editedAt;
void open() => context.push(RoutePaths.note(studysetId, note.id)); void open() => context.push(RoutePaths.note(studysetId, note.id));
// A Viewer opens it read-only and changes nothing (Halkyon's ADR 0044,
// D35).
final viewOnly = StudysetAccess.of(context).viewOnly;
return MenuAnchor( return MenuAnchor(
menuChildren: [ menuChildren: [
MenuItemButton( if (!viewOnly) ...[
leadingIcon: const Icon(LucideIcons.pencil), MenuItemButton(
onPressed: open, leadingIcon: const Icon(LucideIcons.pencil),
child: Text(l10n.noteEdit), onPressed: open,
), child: Text(l10n.noteEdit),
MenuItemButton(
leadingIcon: Icon(
LucideIcons.trash2,
color: Theme.of(context).colorScheme.error,
), ),
onPressed: () => _delete(context), MenuItemButton(
child: Text( leadingIcon: Icon(
l10n.noteDelete, LucideIcons.trash2,
style: TextStyle(color: Theme.of(context).colorScheme.error), color: Theme.of(context).colorScheme.error,
),
onPressed: () => _delete(context),
child: Text(
l10n.noteDelete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
), ),
), ],
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.info), leadingIcon: const Icon(LucideIcons.info),
onPressed: () => onPressed: () =>

View file

@ -11,6 +11,7 @@ import 'package:halkyon_learn_flutter/src/core/l10n/file_size.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart'; import 'package:halkyon_learn_flutter/src/core/sync/uuid_time.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_counts_line.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/bloc/pdf_list_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/deadline_status.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart'; import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_chapter.dart';
@ -130,7 +131,9 @@ class const PdfListToolbar({
if (!state.loaded || state.rows.isEmpty) { if (!state.loaded || state.rows.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final canReorder = state.rows.length >= 2; // A Viewer puts nothing in another order (Halkyon's ADR 0044, D35).
final canReorder =
state.rows.length >= 2 && !StudysetAccess.of(context).viewOnly;
if (reordering || (canReorder && folded < mostFolded)) { if (reordering || (canReorder && folded < mostFolded)) {
final reorder = ReorderToggle( final reorder = ReorderToggle(
reordering: reordering, reordering: reordering,
@ -359,32 +362,39 @@ class const _PdfTile({
final bloc = context.read<PdfListBloc>(); final bloc = context.read<PdfListBloc>();
final id = row.source.id; final id = row.source.id;
final pageCount = content.pageCount; final pageCount = content.pageCount;
// A Viewer changes nothing of the file, only their own reading
// (Halkyon's ADR 0044, D35).
final viewOnly = StudysetAccess.of(context).viewOnly;
return [ return [
MenuItemButton( if (!viewOnly) ...[
leadingIcon: const Icon(LucideIcons.pencil), MenuItemButton(
onPressed: () => unawaited(_rename(context)), leadingIcon: const Icon(LucideIcons.pencil),
child: Text(l10n.pdfRename), onPressed: () => unawaited(_rename(context)),
), child: Text(l10n.pdfRename),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.calendarClock),
onPressed: () async {
switch (await chooseReadingDeadline(
context,
row.source,
chapters: chaptersOfPdf(context, row.source),
)) {
case DeadlineSet(:final deadline):
bloc.add(PdfListEventDeadlineSet(id, deadline));
case DeadlineRemoved():
bloc.add(PdfListEventDeadlineSet(id, null));
case null:
break;
}
},
child: Text(
content.deadline == null ? l10n.pdfSetDeadline : l10n.pdfEditDeadline,
), ),
), MenuItemButton(
leadingIcon: const Icon(LucideIcons.calendarClock),
onPressed: () async {
switch (await chooseReadingDeadline(
context,
row.source,
chapters: chaptersOfPdf(context, row.source),
)) {
case DeadlineSet(:final deadline):
bloc.add(PdfListEventDeadlineSet(id, deadline));
case DeadlineRemoved():
bloc.add(PdfListEventDeadlineSet(id, null));
case null:
break;
}
},
child: Text(
content.deadline == null
? l10n.pdfSetDeadline
: l10n.pdfEditDeadline,
),
),
],
if (pageCount != null && (row.furthestPage ?? 0) < pageCount) if (pageCount != null && (row.furthestPage ?? 0) < pageCount)
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(LucideIcons.bookCheck), leadingIcon: const Icon(LucideIcons.bookCheck),
@ -411,11 +421,12 @@ class const _PdfTile({
onPressed: () => unawaited(_moreInfo(context)), onPressed: () => unawaited(_moreInfo(context)),
child: Text(l10n.pdfMoreInfo), child: Text(l10n.pdfMoreInfo),
), ),
MenuItemButton( if (!viewOnly)
leadingIcon: Icon(LucideIcons.trash2, color: error), MenuItemButton(
onPressed: () => unawaited(_delete(context)), leadingIcon: Icon(LucideIcons.trash2, color: error),
child: Text(l10n.pdfDelete, style: TextStyle(color: error)), onPressed: () => unawaited(_delete(context)),
), child: Text(l10n.pdfDelete, style: TextStyle(color: error)),
),
]; ];
} }

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/learn_content.dart';
import 'package:halkyon_learn_flutter/src/core/japanese/kana_input.dart'; import 'package:halkyon_learn_flutter/src/core/japanese/kana_input.dart';
import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart'; import 'package:halkyon_learn_flutter/src/core/l10n/language_tags.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart'; import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart'; import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart';
@ -125,6 +126,9 @@ class const StudyCardHeader({
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final error = Theme.of(context).colorScheme.error; final error = Theme.of(context).colorScheme.error;
// A Viewer changes no card; nobody buries or reports one every member
// shares (Halkyon's ADR 0044, D35, D36).
final access = StudysetAccess.of(context);
PopupMenuItem<_CardAction> item( PopupMenuItem<_CardAction> item(
_CardAction action, _CardAction action,
IconData icon, IconData icon,
@ -171,21 +175,25 @@ class const StudyCardHeader({
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd), item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd),
item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit), if (!access.viewOnly)
item(_CardAction.report, LucideIcons.flag, l10n.cardReportTitle), item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit),
if (!access.shared)
item(_CardAction.report, LucideIcons.flag, l10n.cardReportTitle),
if (onAsk != null) if (onAsk != null)
item( item(
_CardAction.ask, _CardAction.ask,
LucideIcons.messageCircleQuestionMark, LucideIcons.messageCircleQuestionMark,
l10n.discussionAskAbout, l10n.discussionAskAbout,
), ),
item(_CardAction.bury, LucideIcons.moon, l10n.studyBuryCard), if (!access.shared)
item( item(_CardAction.bury, LucideIcons.moon, l10n.studyBuryCard),
_CardAction.delete, if (!access.viewOnly)
LucideIcons.trash2, item(
l10n.cardTileDelete, _CardAction.delete,
color: error, LucideIcons.trash2,
), l10n.cardTileDelete,
color: error,
),
item(_CardAction.info, LucideIcons.info, l10n.moreInfo), item(_CardAction.info, LucideIcons.info, l10n.moreInfo),
], ],
), ),

View file

@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart'; import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart'; import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/core/widget/studyset_access.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart'; import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart'; import 'package:halkyon_learn_flutter/src/feature/assistant/model/assistant_topic.dart';
@ -505,26 +506,9 @@ class _StudysetViewState() extends State<_StudysetView> {
// them apart by space rather than a line (decided 2026-10-12). // them apart by space rather than a line (decided 2026-10-12).
const SizedBox(height: headerGap), const SizedBox(height: headerGap),
if (studyset.viewOnly) if (studyset.viewOnly)
Padding( const Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: EdgeInsets.symmetric(horizontal: 12),
child: Row( child: ViewOnlyNote(),
children: [
Icon(
LucideIcons.eye,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context).studysetViewOnly,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
), ),
if (!wide) if (!wide)
Padding( Padding(

View file

@ -0,0 +1,150 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_tile.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_note_gateway.dart';
import '../../support/fake_studyset_gateway.dart';
import '../../support/pump_app.dart';
import '../../support/test_dependencies.dart';
/// What a member of a shared studyset may do in it (Halkyon's ADR 0044,
/// D35, D36): a Viewer opens cards and notes read-only and is offered
/// nothing that writes; nobody buries or reports a card every member
/// shares; in the person's own studyset, all of it as before.
void main() {
late FakeStudysetGateway studysets;
late FakeCardGateway cardGateway;
late FakeNoteGateway noteGateway;
late String cardId;
late String noteId;
setUp(() async {
studysets = FakeStudysetGateway()
..receiveRemoteChange(
'japanese',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
);
cardGateway = FakeCardGateway();
noteGateway = FakeNoteGateway();
final cards = CardRepository(cardGateway);
addTearDown(cards.dispose);
cardId = await cards.create(
'japanese',
CardContent(
front: RichContent.plain('Capital of Japan?'),
back: RichContent.plain('Tokyo'),
),
);
final notes = NoteRepository(noteGateway);
addTearDown(notes.dispose);
noteId = (await notes.create(
'japanese',
NoteContent(title: 'Particles', body: RichContent.plain('は')),
)).noteId;
});
Future<void> showApp(WidgetTester tester, String location) async {
tester.view.physicalSize = const Size(1200, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await pumpApp(
tester,
testDependencies(
studysetGateway: studysets,
cardGateway: cardGateway,
noteGateway: noteGateway,
),
location: location,
);
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
}
/// The options of the card's row in the Flashcards tab, opened.
Future<void> openCardOptions(WidgetTester tester) async {
await showApp(tester, RoutePaths.studyset('japanese'));
await tester.tap(find.text('Flashcards'));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(CardTile),
matching: find.byTooltip('Options'),
),
);
await tester.pumpAndSettle();
}
void shared({required bool viewOnly}) =>
studysets.shared['japanese'] = (space: 'space-1', viewOnly: viewOnly);
testWidgets("a Viewer's card offers its info alone", (tester) async {
shared(viewOnly: true);
await openCardOptions(tester);
expect(find.text('More info'), findsOneWidget);
expect(find.text('Edit'), findsNothing);
expect(find.text('Delete'), findsNothing);
expect(find.text('Bury until tomorrow'), findsNothing);
expect(find.text('Report an error'), findsNothing);
});
testWidgets("an Editor's card is edited and deleted, never buried or "
'reported for everyone', (tester) async {
shared(viewOnly: false);
await openCardOptions(tester);
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Delete'), findsOneWidget);
expect(find.text('Bury until tomorrow'), findsNothing);
expect(find.text('Report an error'), findsNothing);
});
testWidgets("the person's own card offers all of it", (tester) async {
await openCardOptions(tester);
expect(find.text('Edit'), findsOneWidget);
expect(find.text('Bury until tomorrow'), findsOneWidget);
expect(find.text('Report an error'), findsOneWidget);
});
testWidgets('a Viewer opens a card read-only, adding none', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.card('japanese', cardId));
expect(
find.text('You can view and study this, not change it.'),
findsOneWidget,
);
expect(find.text('New flashcard'), findsNothing);
expect(find.byTooltip('More actions'), findsNothing);
expect(find.byType(RichContentEditor), findsNothing);
expect(
find.textContaining('Capital of Japan?', findRichText: true),
findsWidgets,
);
});
testWidgets('a Viewer opens a note read-only', (tester) async {
shared(viewOnly: true);
await showApp(tester, RoutePaths.note('japanese', noteId));
expect(
find.text('You can view and study this, not change it.'),
findsOneWidget,
);
expect(find.byType(RichContentEditor), findsNothing);
expect(
tester.widget<TextField>(find.byType(TextField).first).readOnly,
isTrue,
);
});
}

View file

@ -189,6 +189,15 @@ member reads everything in the space.
recommendation): the first seal bringing a key ID the space never had, in the same recommendation): the first seal bringing a key ID the space never had, in the same
transaction. Members the rolling device missed (keys changed, not trusted anew) get transaction. Members the rolling device missed (keys changed, not trusted anew) get
it later as newcomers do; waiting for every member would roll again and again. it later as newcomers do; waiting for every member would roll again and again.
- **D35.** **A Viewer opens cards, notes and steps read-only** (decided 11 Nov 2026, the
agent's recommendation, reading D30): the same screens as an Editor's, their content
shown but not editable, without Save, saying the studyset is theirs to view and
study; the lists' menus offer them nothing that writes.
- **D36.** **Burying and reporting a card are hidden in shared studysets for now**
(decided 11 Nov 2026, the agent's recommendation): both are written on the card's
row, so one member's would be everyone's, and a Viewer's would be refused. They return
once they are each person's own rows by the card's ID, as reviews are (D26), a change
of the synced tables shipped with a release.
## Steps ## Steps
@ -209,8 +218,8 @@ member reads everything in the space.
@handle, the members, their roles, leaving), each member's own reviews. @handle, the members, their roles, leaving), each member's own reviews.
Built 11 Nov 2026 (D27 to D34): the copy into a space of its own, the Share sheet Built 11 Nov 2026 (D27 to D34): the copy into a space of its own, the Share sheet
with its members, roles, leaving and safety codes, a Viewer's screens read-only, with its members, roles, leaving and safety codes, a Viewer's screens read-only,
and the key rolled once a member goes. Left: a Viewer's per-item buttons inside the the key rolled once a member goes, and a Viewer's material opened read-only (D35),
material tabs, and a message for a write the engine refuses them. burying and reporting hidden in shared studysets (D36).
5. **Invitations by email and by link** (D4, D5), the Inbox. 5. **Invitations by email and by link** (D4, D5), the Inbox.
6. **Agents** (D6, D7, D10, D12). 6. **Agents** (D6, D7, D10, D12).