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
17 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/route_paths.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/card/bloc/card_composer_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_screen.dart';
@ -427,7 +428,13 @@ Widget _inStudyset(
color:
PaletteColor.byName(colorNameOf(studyset.id, all))?.background ??
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/editor/core_blocks.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';
/// halkyon_editor's [RichContentEditor] with Learn's blocks ([blocksOf]) and,
/// 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({
/// The content the editor opens with; later changes to it are ignored.
required final RichContent initialContent,
@ -32,16 +34,23 @@ class const LearnContentEditor({
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => RichContentEditor(
initialContent: initialContent,
onChanged: onChanged,
autofocus: autofocus,
focusNode: focusNode,
formatBar: formatBar,
padding: padding,
blocks: blocksOf(context),
wrapEditor: grammar == null ? null : _checked,
);
Widget build(BuildContext context) => StudysetAccess.of(context).viewOnly
// A Viewer reads what the editor would hold, unchanged (Halkyon's ADR
// 0044, D35); a long text scrolls where the editor would have.
? SingleChildScrollView(
padding: padding,
child: LearnContentView(content: initialContent),
)
: RichContentEditor(
initialContent: initialContent,
onChanged: onChanged,
autofocus: autofocus,
focusNode: focusNode,
formatBar: formatBar,
padding: padding,
blocks: blocksOf(context),
wrapEditor: grammar == null ? null : _checked,
);
Widget _checked(
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/router/route_paths.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/model/card.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 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
// back, and says so in place of the message that they moved.
return BlocListener<CardBrowserBloc, CardBrowserState>(
@ -370,8 +373,9 @@ class _BrowserState() extends State<_Browser> {
child: AppLoadingIndicator(),
),
CardBrowserState(cards: []) => _Empty(
onCreate: () =>
context.push(RoutePaths.newCard(widget.studyset.id)),
onCreate: viewOnly
? null
: () => context.push(RoutePaths.newCard(widget.studyset.id)),
),
_ => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -486,11 +490,12 @@ class _BrowserState() extends State<_Browser> {
label: Text(l10n.cardBrowserStudy),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: write,
icon: const Icon(LucideIcons.squarePen, size: 18),
label: Text(l10n.cardBrowserEditor),
),
if (!viewOnly)
TextButton.icon(
onPressed: write,
icon: const Icon(LucideIcons.squarePen, size: 18),
label: Text(l10n.cardBrowserEditor),
),
] else ...[
IconButton.filled(
style: filledIconButtonStyle(context),
@ -498,11 +503,12 @@ class _BrowserState() extends State<_Browser> {
icon: const Icon(LucideIcons.play),
tooltip: l10n.cardBrowserStudy,
),
IconButton(
onPressed: write,
icon: const Icon(LucideIcons.squarePen),
tooltip: l10n.cardBrowserEditor,
),
if (!viewOnly)
IconButton(
onPressed: write,
icon: const Icon(LucideIcons.squarePen),
tooltip: l10n.cardBrowserEditor,
),
],
PopupMenuButton<_Menu>(
icon: const Icon(LucideIcons.ellipsisVertical),
@ -512,15 +518,16 @@ class _BrowserState() extends State<_Browser> {
_Menu.tags => unawaited(showManageTagsSheet(context)),
},
itemBuilder: (context) => [
PopupMenuItem(
value: _Menu.import,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
if (!viewOnly)
PopupMenuItem(
value: _Menu.import,
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.fileInput),
title: Text(l10n.cardBrowserImport),
),
),
),
PopupMenuItem(
value: _Menu.tags,
child: ListTile(
@ -549,12 +556,15 @@ class _BrowserState() extends State<_Browser> {
_Actions _actions(CardBrowserState state) {
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 (
move: some ? () => _move(state) : null,
move: writes ? () => _move(state) : null,
addTags: some ? () => _addTags(state) : null,
removeTags: some ? () => _removeTags(state) : null,
reset: some ? () => _resetProgress(state) : null,
delete: some ? () => _delete(state) : null,
delete: writes ? () => _delete(state) : null,
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.
class const _Empty({required final VoidCallback onCreate})
class const _Empty({required final VoidCallback? onCreate})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -1715,12 +1725,14 @@ class const _Empty({required final VoidCallback onCreate})
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onCreate,
icon: const Icon(LucideIcons.plus, size: 18),
label: Text(l10n.cardBrowserCreate),
),
if (onCreate != null) ...[
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onCreate,
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/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/widget/report_sheet.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 hasMeta = tags.isNotEmpty;
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(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Material(
@ -131,54 +136,58 @@ class const CardTile({
),
MenuAnchor(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(
LucideIcons.pencil,
size: 18,
),
onPressed: onOpen,
child: Text(l10n.cardTileEdit),
),
if (report == null)
if (!access.viewOnly)
MenuItemButton(
leadingIcon: const Icon(
LucideIcons.flag,
LucideIcons.pencil,
size: 18,
),
onPressed: onReport,
child: Text(l10n.cardReportTitle),
)
else
onPressed: onOpen,
child: Text(l10n.cardTileEdit),
),
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(
leadingIcon: const Icon(
LucideIcons.flagOff,
leadingIcon: Icon(
buried ? LucideIcons.sunrise : LucideIcons.moon,
size: 18,
),
onPressed: onResolve,
child: Text(l10n.cardResolveReport),
onPressed: onBuryToggle,
child: Text(
buried ? l10n.cardUnbury : l10n.studyBuryCard,
),
),
MenuItemButton(
leadingIcon: Icon(
buried ? LucideIcons.sunrise : LucideIcons.moon,
size: 18,
if (!access.viewOnly)
MenuItemButton(
leadingIcon: Icon(
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(
leadingIcon: const Icon(LucideIcons.info, size: 18),
onPressed: onInfo,

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.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/widget/composer/card_draft_editor.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,
selected: _selected,
expanded: expanded,
onAdd: _addDraft,
// A Viewer adds none (Halkyon's ADR 0044, D35).
onAdd: StudysetAccess.of(context).viewOnly
? null
: _addDraft,
onSelect: _select,
onToggle: () =>
setState(() => _stackExpanded = !expanded),
@ -307,7 +311,9 @@ class const _Stack({
required final CardComposerState state,
required final String? selected,
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 VoidCallback onToggle,
}) extends StatelessWidget {
@ -324,21 +330,23 @@ class const _Stack({
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (expanded)
FilledButton.tonalIcon(
onPressed: onAdd,
icon: const Icon(LucideIcons.plus),
label: Text(l10n.composerNewFlashcard),
)
else
Center(
child: IconButton.filledTonal(
tooltip: l10n.composerNewFlashcard,
if (onAdd != null) ...[
if (expanded)
FilledButton.tonalIcon(
onPressed: onAdd,
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(
child: ListView.separated(
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/widget/conflict_versions.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/model/card_content.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) {
// A Viewer's changes go nowhere (Halkyon's ADR 0044, D35).
if (StudysetAccess.viewOnlyNow(context)) {
return;
}
_sent = content;
context.read<CardComposerBloc>().add(
CardComposerEventChanged(widget.draft.id, content),
@ -304,6 +309,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (StudysetAccess.of(context).viewOnly) const ViewOnlyNote(),
Row(
children: [
TagsPill(
@ -332,18 +338,19 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
),
),
const SizedBox(width: 8),
_Actions(
onAddHint: content.hints.length < CardContent.maxHints
? _addHint
: null,
onAddExplanation: content.explanation == null
? _addExplanation
: null,
onDuplicate: widget.onDuplicate,
onClear: () => _send(CardComposerEventCleared(draft.id)),
onDelete: widget.onDelete,
onSuggest: widget.onSuggest,
),
if (!StudysetAccess.of(context).viewOnly)
_Actions(
onAddHint: content.hints.length < CardContent.maxHints
? _addHint
: null,
onAddExplanation: content.explanation == null
? _addExplanation
: null,
onDuplicate: widget.onDuplicate,
onClear: () => _send(CardComposerEventCleared(draft.id)),
onDelete: widget.onDelete,
onSuggest: widget.onSuggest,
),
],
),
const SizedBox(height: 16),

View file

@ -1,6 +1,7 @@
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/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/kanji/widget/character_readings.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -80,6 +81,7 @@ class _CharacterFieldsState() extends State<CharacterFields> {
Text(label, style: labelStyle),
const SizedBox(height: 8),
TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: controller,
@ -106,6 +108,7 @@ class _CharacterFieldsState() extends State<CharacterFields> {
children: [
Expanded(
child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: meaning,

View file

@ -1,4 +1,5 @@
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_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -99,6 +100,7 @@ class _MatchingFieldsState() extends State<MatchingFields> {
children: [
Expanded(
child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: target,
@ -154,6 +156,7 @@ class _MatchingFieldsState() extends State<MatchingFields> {
Expanded(
flex: 2,
child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: item,

View file

@ -1,6 +1,7 @@
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/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/jmdict_tags.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -69,6 +70,7 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
Text(l10n.vocabularyReading, style: labelStyle),
const SizedBox(height: 8),
TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: _reading,
@ -103,6 +105,7 @@ class _VocabularyFieldsState() extends State<VocabularyFields> {
children: [
Expanded(
child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
controller: meaning,
@ -237,6 +240,7 @@ class _SenseFieldsState() extends State<_SenseFields> {
children: [
Expanded(
child: TextField(
readOnly: StudysetAccess.of(context).viewOnly,
maxLength: TextLimits.phrase,
buildCounter: nearLimitCounter,
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/src/core/dependencies/person_dependencies.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/model/learning_path.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) {
final l10n = AppLocalizations.of(context);
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(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
onPressed: () =>
context.push(RoutePaths.editPath(studysetId, path.id)),
child: Text(l10n.pathEdit),
),
if (!viewOnly)
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
onPressed: () =>
context.push(RoutePaths.editPath(studysetId, path.id)),
child: Text(l10n.pathEdit),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.rotateCcw),
onPressed: () => _reset(context),
child: Text(l10n.pathResetProgress),
),
MenuItemButton(
leadingIcon: Icon(LucideIcons.trash2, color: error),
onPressed: () => _delete(context),
child: Text(l10n.pathDelete, style: TextStyle(color: error)),
),
if (!viewOnly)
MenuItemButton(
leadingIcon: Icon(LucideIcons.trash2, color: error),
onPressed: () => _delete(context),
child: Text(l10n.pathDelete, style: TextStyle(color: error)),
),
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info),
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/grammar/grammar_host.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/feedback/widget/feedback_actions.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) {
final l10n = AppLocalizations.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(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Align(
@ -150,7 +155,8 @@ class const _Note({
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (readOnly) _Conflicts(state: state),
if (conflicted) _Conflicts(state: state),
if (viewOnly) const ViewOnlyNote(),
Expanded(
child: SectionCard(
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/src/core/dependencies/person_dependencies.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/model/note.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 editedAt = note.editedAt;
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(
menuChildren: [
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
onPressed: open,
child: Text(l10n.noteEdit),
),
MenuItemButton(
leadingIcon: Icon(
LucideIcons.trash2,
color: Theme.of(context).colorScheme.error,
if (!viewOnly) ...[
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
onPressed: open,
child: Text(l10n.noteEdit),
),
onPressed: () => _delete(context),
child: Text(
l10n.noteDelete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
MenuItemButton(
leadingIcon: Icon(
LucideIcons.trash2,
color: Theme.of(context).colorScheme.error,
),
onPressed: () => _delete(context),
child: Text(
l10n.noteDelete,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
],
MenuItemButton(
leadingIcon: const Icon(LucideIcons.info),
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/sync/uuid_time.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/model/deadline_status.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) {
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)) {
final reorder = ReorderToggle(
reordering: reordering,
@ -359,32 +362,39 @@ class const _PdfTile({
final bloc = context.read<PdfListBloc>();
final id = row.source.id;
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 [
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
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,
if (!viewOnly) ...[
MenuItemButton(
leadingIcon: const Icon(LucideIcons.pencil),
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,
),
),
],
if (pageCount != null && (row.furthestPage ?? 0) < pageCount)
MenuItemButton(
leadingIcon: const Icon(LucideIcons.bookCheck),
@ -411,11 +421,12 @@ class const _PdfTile({
onPressed: () => unawaited(_moreInfo(context)),
child: Text(l10n.pdfMoreInfo),
),
MenuItemButton(
leadingIcon: Icon(LucideIcons.trash2, color: error),
onPressed: () => unawaited(_delete(context)),
child: Text(l10n.pdfDelete, style: TextStyle(color: error)),
),
if (!viewOnly)
MenuItemButton(
leadingIcon: Icon(LucideIcons.trash2, 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/l10n/language_tags.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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_extras.dart';
@ -125,6 +126,9 @@ class const StudyCardHeader({
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
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(
_CardAction action,
IconData icon,
@ -171,21 +175,25 @@ class const StudyCardHeader({
},
itemBuilder: (context) => [
item(_CardAction.tags, LucideIcons.tag, l10n.cardTagsAdd),
item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit),
item(_CardAction.report, LucideIcons.flag, l10n.cardReportTitle),
if (!access.viewOnly)
item(_CardAction.edit, LucideIcons.pencil, l10n.cardTileEdit),
if (!access.shared)
item(_CardAction.report, LucideIcons.flag, l10n.cardReportTitle),
if (onAsk != null)
item(
_CardAction.ask,
LucideIcons.messageCircleQuestionMark,
l10n.discussionAskAbout,
),
item(_CardAction.bury, LucideIcons.moon, l10n.studyBuryCard),
item(
_CardAction.delete,
LucideIcons.trash2,
l10n.cardTileDelete,
color: error,
),
if (!access.shared)
item(_CardAction.bury, LucideIcons.moon, l10n.studyBuryCard),
if (!access.viewOnly)
item(
_CardAction.delete,
LucideIcons.trash2,
l10n.cardTileDelete,
color: error,
),
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/person_dependencies.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/widget/anki_import_sheet.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).
const SizedBox(height: headerGap),
if (studyset.viewOnly)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 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,
),
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: ViewOnlyNote(),
),
if (!wide)
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,
);
});
}