Import Anki decks with their files and history
Files in content: the person's images and sounds as asset rows synced like
all content, their files kept by hash on each device, uploaded after each
sync, downloaded on demand and checked against their hash
(core/asset/AssetRepository). Rich text names them as asset:<id> URLs, in
the editor's image block and an audio block of ours; any other URL is never
fetched. Sounds play through audioplayers, one at a time. Content holding
only an image or a sound isn't blank, and lists show it as such.
The Anki import ("Import from Anki" under Add material): .apkg and .colpkg
packages in all three formats Anki has written, read on an isolate of their
own. Modern packages compress everything with zstd in frames that don't
record their size, which pure_zstd v1.0.0 refuses; our fork decodes them.
Fields become rich text by a converter of our own (lines, lists, formatting,
images, sounds, cloze deletions hidden on the front). One card per note,
the files first, studysets as chosen (flatten, sub-studysets, or tags by
subdeck), the notes' tags, and, if wanted, the review history, which FSRS
replays: the first template's reviews to the card's schedule, a reversed
note type's second to the reverse one.
Imported cards say where they came from in their first revision (card
schema 3); the dashboard doesn't count them as written. The test fixtures
are exported by Anki itself (tool/anki/make_fixtures.py).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3fbb45574f
commit
a6d5769a2e
77 changed files with 5643 additions and 37 deletions
|
|
@ -372,5 +372,75 @@
|
|||
"commonUndo": "Undo",
|
||||
"commonTryAgain": "Try again",
|
||||
"sessionUnreachable": "Couldn't reach the server.",
|
||||
"notFoundGone": "This no longer exists. It may have been deleted on another device."
|
||||
"notFoundGone": "This no longer exists. It may have been deleted on another device.",
|
||||
"fileImage": "Image",
|
||||
"fileSound": "Sound",
|
||||
"fileImageUnavailable": "This image isn't on this device yet. It shows once it's uploaded and you're online.",
|
||||
"fileFromElsewhere": "Files from the web aren't shown.",
|
||||
"filePlaySound": "Play sound",
|
||||
"fileStopSound": "Stop sound",
|
||||
"fileSoundUnavailable": "This sound isn't on this device yet. It plays once it's uploaded and you're online.",
|
||||
"ankiImportMenu": "Import from Anki",
|
||||
"ankiImportFileType": "Anki decks",
|
||||
"ankiImportTitle": "Import Anki deck",
|
||||
"ankiImportOptionsTitle": "How should subdecks be imported?",
|
||||
"ankiImportReading": "Reading the deck…",
|
||||
"ankiImportSummary": "{cards, plural, =1{1 card.} other{{cards} cards.}}",
|
||||
"@ankiImportSummary": {
|
||||
"placeholders": {
|
||||
"cards": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"ankiImportSummaryDecks": "{cards, plural, =1{1 card} other{{cards} cards}} across {decks} subdecks.",
|
||||
"@ankiImportSummaryDecks": {
|
||||
"placeholders": {
|
||||
"cards": {"type": "int"},
|
||||
"decks": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"ankiImportModeFlatten": "Flatten into this studyset",
|
||||
"ankiImportModeFlattenSubtitle": "Import every card here and ignore the subdecks.",
|
||||
"ankiImportModeSubStudysets": "Recreate as sub-studysets",
|
||||
"ankiImportModeSubStudysetsSubtitle": "Mirror the subdeck tree: each subdeck becomes a nested studyset.",
|
||||
"ankiImportModeTag": "Tag cards by subdeck",
|
||||
"ankiImportModeTagSubtitle": "Keep every card here, tagged with each subdeck it came from.",
|
||||
"ankiImportProgress": "Import study progress",
|
||||
"ankiImportProgressSubtitle": "Bring Anki's review history over: cards continue where they left off. New cards stay new.",
|
||||
"ankiImportMedia": "{count, plural, =1{Include media (1 file)} other{Include media ({count} files)}}",
|
||||
"@ankiImportMedia": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"ankiImportMediaSubtitle": "Keep the deck's images and sounds on the cards.",
|
||||
"ankiImportRunning": "Importing…",
|
||||
"ankiImportConfirm": "Import",
|
||||
"ankiImportNotAPackage": "This isn't an Anki deck. In Anki, export a deck as an .apkg file and import that.",
|
||||
"ankiImportNewerFormat": "This deck comes from a newer Anki than this app knows. Export it again with “Support older Anki versions”.",
|
||||
"ankiImportUnreadable": "This deck is damaged and can't be read.",
|
||||
"ankiImportBrokeOff": "The import broke off. The cards it brought in so far stay.",
|
||||
"ankiImported": "{count, plural, =1{Imported 1 flashcard from Anki.} other{Imported {count} flashcards from Anki.}}",
|
||||
"@ankiImported": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"ankiImportedSubStudysets": "{count, plural, =1{Imported 1 card into sub-studysets.} other{Imported {count} cards into sub-studysets.}}",
|
||||
"@ankiImportedSubStudysets": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"ankiImportedTagged": "{count, plural, =1{Imported 1 card, tagged by subdeck.} other{Imported {count} cards, tagged by subdeck.}}",
|
||||
"@ankiImportedTagged": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"ankiImportFilesSkipped": "{count, plural, =1{1 media file couldn't be imported.} other{{count} media files couldn't be imported.}}",
|
||||
"@ankiImportFilesSkipped": {
|
||||
"placeholders": {
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2013,6 +2013,210 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'This no longer exists. It may have been deleted on another device.'**
|
||||
String get notFoundGone;
|
||||
|
||||
/// No description provided for @fileImage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Image'**
|
||||
String get fileImage;
|
||||
|
||||
/// No description provided for @fileSound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Sound'**
|
||||
String get fileSound;
|
||||
|
||||
/// No description provided for @fileImageUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This image isn\'t on this device yet. It shows once it\'s uploaded and you\'re online.'**
|
||||
String get fileImageUnavailable;
|
||||
|
||||
/// No description provided for @fileFromElsewhere.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Files from the web aren\'t shown.'**
|
||||
String get fileFromElsewhere;
|
||||
|
||||
/// No description provided for @filePlaySound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Play sound'**
|
||||
String get filePlaySound;
|
||||
|
||||
/// No description provided for @fileStopSound.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stop sound'**
|
||||
String get fileStopSound;
|
||||
|
||||
/// No description provided for @fileSoundUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This sound isn\'t on this device yet. It plays once it\'s uploaded and you\'re online.'**
|
||||
String get fileSoundUnavailable;
|
||||
|
||||
/// No description provided for @ankiImportMenu.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import from Anki'**
|
||||
String get ankiImportMenu;
|
||||
|
||||
/// No description provided for @ankiImportFileType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Anki decks'**
|
||||
String get ankiImportFileType;
|
||||
|
||||
/// No description provided for @ankiImportTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import Anki deck'**
|
||||
String get ankiImportTitle;
|
||||
|
||||
/// No description provided for @ankiImportOptionsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'How should subdecks be imported?'**
|
||||
String get ankiImportOptionsTitle;
|
||||
|
||||
/// No description provided for @ankiImportReading.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Reading the deck…'**
|
||||
String get ankiImportReading;
|
||||
|
||||
/// No description provided for @ankiImportSummary.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{cards, plural, =1{1 card.} other{{cards} cards.}}'**
|
||||
String ankiImportSummary(int cards);
|
||||
|
||||
/// No description provided for @ankiImportSummaryDecks.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{cards, plural, =1{1 card} other{{cards} cards}} across {decks} subdecks.'**
|
||||
String ankiImportSummaryDecks(int cards, int decks);
|
||||
|
||||
/// No description provided for @ankiImportModeFlatten.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Flatten into this studyset'**
|
||||
String get ankiImportModeFlatten;
|
||||
|
||||
/// No description provided for @ankiImportModeFlattenSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import every card here and ignore the subdecks.'**
|
||||
String get ankiImportModeFlattenSubtitle;
|
||||
|
||||
/// No description provided for @ankiImportModeSubStudysets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recreate as sub-studysets'**
|
||||
String get ankiImportModeSubStudysets;
|
||||
|
||||
/// No description provided for @ankiImportModeSubStudysetsSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mirror the subdeck tree: each subdeck becomes a nested studyset.'**
|
||||
String get ankiImportModeSubStudysetsSubtitle;
|
||||
|
||||
/// No description provided for @ankiImportModeTag.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tag cards by subdeck'**
|
||||
String get ankiImportModeTag;
|
||||
|
||||
/// No description provided for @ankiImportModeTagSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep every card here, tagged with each subdeck it came from.'**
|
||||
String get ankiImportModeTagSubtitle;
|
||||
|
||||
/// No description provided for @ankiImportProgress.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import study progress'**
|
||||
String get ankiImportProgress;
|
||||
|
||||
/// No description provided for @ankiImportProgressSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Bring Anki\'s review history over: cards continue where they left off. New cards stay new.'**
|
||||
String get ankiImportProgressSubtitle;
|
||||
|
||||
/// No description provided for @ankiImportMedia.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Include media (1 file)} other{Include media ({count} files)}}'**
|
||||
String ankiImportMedia(int count);
|
||||
|
||||
/// No description provided for @ankiImportMediaSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Keep the deck\'s images and sounds on the cards.'**
|
||||
String get ankiImportMediaSubtitle;
|
||||
|
||||
/// No description provided for @ankiImportRunning.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Importing…'**
|
||||
String get ankiImportRunning;
|
||||
|
||||
/// No description provided for @ankiImportConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import'**
|
||||
String get ankiImportConfirm;
|
||||
|
||||
/// No description provided for @ankiImportNotAPackage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This isn\'t an Anki deck. In Anki, export a deck as an .apkg file and import that.'**
|
||||
String get ankiImportNotAPackage;
|
||||
|
||||
/// No description provided for @ankiImportNewerFormat.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This deck comes from a newer Anki than this app knows. Export it again with “Support older Anki versions”.'**
|
||||
String get ankiImportNewerFormat;
|
||||
|
||||
/// No description provided for @ankiImportUnreadable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This deck is damaged and can\'t be read.'**
|
||||
String get ankiImportUnreadable;
|
||||
|
||||
/// No description provided for @ankiImportBrokeOff.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'The import broke off. The cards it brought in so far stay.'**
|
||||
String get ankiImportBrokeOff;
|
||||
|
||||
/// No description provided for @ankiImported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Imported 1 flashcard from Anki.} other{Imported {count} flashcards from Anki.}}'**
|
||||
String ankiImported(int count);
|
||||
|
||||
/// No description provided for @ankiImportedSubStudysets.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Imported 1 card into sub-studysets.} other{Imported {count} cards into sub-studysets.}}'**
|
||||
String ankiImportedSubStudysets(int count);
|
||||
|
||||
/// No description provided for @ankiImportedTagged.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Imported 1 card, tagged by subdeck.} other{Imported {count} cards, tagged by subdeck.}}'**
|
||||
String ankiImportedTagged(int count);
|
||||
|
||||
/// No description provided for @ankiImportFilesSkipped.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 media file couldn\'t be imported.} other{{count} media files couldn\'t be imported.}}'**
|
||||
String ankiImportFilesSkipped(int count);
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -1168,4 +1168,172 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get notFoundGone =>
|
||||
'This no longer exists. It may have been deleted on another device.';
|
||||
|
||||
@override
|
||||
String get fileImage => 'Image';
|
||||
|
||||
@override
|
||||
String get fileSound => 'Sound';
|
||||
|
||||
@override
|
||||
String get fileImageUnavailable =>
|
||||
'This image isn\'t on this device yet. It shows once it\'s uploaded and you\'re online.';
|
||||
|
||||
@override
|
||||
String get fileFromElsewhere => 'Files from the web aren\'t shown.';
|
||||
|
||||
@override
|
||||
String get filePlaySound => 'Play sound';
|
||||
|
||||
@override
|
||||
String get fileStopSound => 'Stop sound';
|
||||
|
||||
@override
|
||||
String get fileSoundUnavailable =>
|
||||
'This sound isn\'t on this device yet. It plays once it\'s uploaded and you\'re online.';
|
||||
|
||||
@override
|
||||
String get ankiImportMenu => 'Import from Anki';
|
||||
|
||||
@override
|
||||
String get ankiImportFileType => 'Anki decks';
|
||||
|
||||
@override
|
||||
String get ankiImportTitle => 'Import Anki deck';
|
||||
|
||||
@override
|
||||
String get ankiImportOptionsTitle => 'How should subdecks be imported?';
|
||||
|
||||
@override
|
||||
String get ankiImportReading => 'Reading the deck…';
|
||||
|
||||
@override
|
||||
String ankiImportSummary(int cards) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
cards,
|
||||
locale: localeName,
|
||||
other: '$cards cards.',
|
||||
one: '1 card.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String ankiImportSummaryDecks(int cards, int decks) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
cards,
|
||||
locale: localeName,
|
||||
other: '$cards cards',
|
||||
one: '1 card',
|
||||
);
|
||||
return '$_temp0 across $decks subdecks.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get ankiImportModeFlatten => 'Flatten into this studyset';
|
||||
|
||||
@override
|
||||
String get ankiImportModeFlattenSubtitle =>
|
||||
'Import every card here and ignore the subdecks.';
|
||||
|
||||
@override
|
||||
String get ankiImportModeSubStudysets => 'Recreate as sub-studysets';
|
||||
|
||||
@override
|
||||
String get ankiImportModeSubStudysetsSubtitle =>
|
||||
'Mirror the subdeck tree: each subdeck becomes a nested studyset.';
|
||||
|
||||
@override
|
||||
String get ankiImportModeTag => 'Tag cards by subdeck';
|
||||
|
||||
@override
|
||||
String get ankiImportModeTagSubtitle =>
|
||||
'Keep every card here, tagged with each subdeck it came from.';
|
||||
|
||||
@override
|
||||
String get ankiImportProgress => 'Import study progress';
|
||||
|
||||
@override
|
||||
String get ankiImportProgressSubtitle =>
|
||||
'Bring Anki\'s review history over: cards continue where they left off. New cards stay new.';
|
||||
|
||||
@override
|
||||
String ankiImportMedia(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Include media ($count files)',
|
||||
one: 'Include media (1 file)',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get ankiImportMediaSubtitle =>
|
||||
'Keep the deck\'s images and sounds on the cards.';
|
||||
|
||||
@override
|
||||
String get ankiImportRunning => 'Importing…';
|
||||
|
||||
@override
|
||||
String get ankiImportConfirm => 'Import';
|
||||
|
||||
@override
|
||||
String get ankiImportNotAPackage =>
|
||||
'This isn\'t an Anki deck. In Anki, export a deck as an .apkg file and import that.';
|
||||
|
||||
@override
|
||||
String get ankiImportNewerFormat =>
|
||||
'This deck comes from a newer Anki than this app knows. Export it again with “Support older Anki versions”.';
|
||||
|
||||
@override
|
||||
String get ankiImportUnreadable => 'This deck is damaged and can\'t be read.';
|
||||
|
||||
@override
|
||||
String get ankiImportBrokeOff =>
|
||||
'The import broke off. The cards it brought in so far stay.';
|
||||
|
||||
@override
|
||||
String ankiImported(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Imported $count flashcards from Anki.',
|
||||
one: 'Imported 1 flashcard from Anki.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String ankiImportedSubStudysets(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Imported $count cards into sub-studysets.',
|
||||
one: 'Imported 1 card into sub-studysets.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String ankiImportedTagged(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Imported $count cards, tagged by subdeck.',
|
||||
one: 'Imported 1 card, tagged by subdeck.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String ankiImportFilesSkipped(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count media files couldn\'t be imported.',
|
||||
one: '1 media file couldn\'t be imported.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
106
halkyon_learn_flutter/lib/src/core/asset/bloc/sound_bloc.dart
Normal file
106
halkyon_learn_flutter/lib/src/core/asset/bloc/sound_bloc.dart
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
|
||||
|
||||
/// One sound in content: plays it when asked, and stops it again; it also
|
||||
/// stops when it ends or another sound starts.
|
||||
class SoundBloc(
|
||||
final AssetRepository _assets,
|
||||
final SoundPlayer _player,
|
||||
|
||||
/// The sound's asset row.
|
||||
final String _assetId,
|
||||
) extends Bloc<SoundEvent, SoundState> {
|
||||
this : super(SoundState.idle) {
|
||||
on<SoundEventToggled>(_onToggled);
|
||||
on<SoundEventPlayingChanged>((event, emit) {
|
||||
if (event.path != null && event.path == _path) {
|
||||
emit(SoundState.playing);
|
||||
} else if (state == SoundState.playing) {
|
||||
emit(SoundState.idle);
|
||||
}
|
||||
});
|
||||
_playing = _player.playing.listen(
|
||||
(path) => add(SoundEventPlayingChanged(path)),
|
||||
);
|
||||
}
|
||||
|
||||
late final StreamSubscription<String?> _playing;
|
||||
|
||||
/// Where this sound's file is, once it was played.
|
||||
String? _path;
|
||||
|
||||
Future<void> _onToggled(
|
||||
SoundEventToggled event,
|
||||
Emitter<SoundState> emit,
|
||||
) async {
|
||||
switch (state) {
|
||||
case SoundState.playing:
|
||||
await _player.stop();
|
||||
case SoundState.loading:
|
||||
break;
|
||||
case SoundState.idle || SoundState.unavailable:
|
||||
emit(SoundState.loading);
|
||||
final file = await _assets.fileOf(_assetId);
|
||||
if (file == null) {
|
||||
emit(SoundState.unavailable);
|
||||
return;
|
||||
}
|
||||
_path = file.path;
|
||||
try {
|
||||
await _player.play(file.path, mimeType: file.mimeType);
|
||||
} on Object catch (error) {
|
||||
log('Playing a sound failed', name: 'Sound', error: error);
|
||||
emit(SoundState.unavailable);
|
||||
}
|
||||
// Playing shows once the player says so.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _playing.cancel();
|
||||
if (state == SoundState.playing) {
|
||||
await _player.stop();
|
||||
}
|
||||
await super.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// What happens to a sound.
|
||||
sealed class const SoundEvent() with Equatable {
|
||||
@override
|
||||
List<Object?> get props => const [];
|
||||
}
|
||||
|
||||
/// Play the sound, or stop it if it plays.
|
||||
final class const SoundEventToggled() extends SoundEvent;
|
||||
|
||||
/// The player plays the file at [path] now, or nothing.
|
||||
final class const SoundEventPlayingChanged(
|
||||
/// The file playing, or null.
|
||||
final String? path,
|
||||
) extends SoundEvent {
|
||||
@override
|
||||
List<Object?> get props => [path];
|
||||
}
|
||||
|
||||
/// How a sound is.
|
||||
enum SoundState() {
|
||||
/// Not playing.
|
||||
idle,
|
||||
|
||||
/// Getting its file.
|
||||
loading,
|
||||
|
||||
/// Playing.
|
||||
playing,
|
||||
|
||||
/// Its file can't be had now (not uploaded yet, or no connection), or
|
||||
/// didn't play.
|
||||
unavailable,
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// The files on this device, by their SHA-256, and which of them wait to be
|
||||
/// uploaded.
|
||||
abstract interface class AssetFileCache() {
|
||||
/// The file with [sha256]; null if this device hasn't got it.
|
||||
Future<Uint8List?> read(String sha256);
|
||||
|
||||
/// Keeps [bytes], whose SHA-256 is [sha256].
|
||||
Future<void> write(String sha256, Uint8List bytes);
|
||||
|
||||
/// Where the file with [sha256] is, or would be.
|
||||
String pathOf(String sha256);
|
||||
|
||||
/// The files that wait to be uploaded.
|
||||
Future<Set<String>> pendingUploads();
|
||||
|
||||
/// Notes that the file with [sha256] waits to be uploaded.
|
||||
Future<void> markPending(String sha256);
|
||||
|
||||
/// Notes that the file with [sha256] doesn't wait any more.
|
||||
Future<void> clearPending(String sha256);
|
||||
}
|
||||
|
||||
/// [AssetFileCache] in a folder of the person's: each file named by its hash,
|
||||
/// and an empty file of the same name in `uploads/` for each that waits.
|
||||
///
|
||||
/// A hash is part of a path here, and rows synced from elsewhere carry them,
|
||||
/// so anything but 64 lowercase hex digits is refused.
|
||||
class DirectoryAssetFileCache(final Directory _folder)
|
||||
implements AssetFileCache {
|
||||
Directory get _uploads => Directory(p.join(_folder.path, 'uploads'));
|
||||
|
||||
@override
|
||||
Future<Uint8List?> read(String sha256) async {
|
||||
try {
|
||||
return await File(pathOf(sha256)).readAsBytes();
|
||||
} on PathNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(String sha256, Uint8List bytes) async {
|
||||
final file = File(pathOf(sha256));
|
||||
if (file.existsSync()) {
|
||||
return;
|
||||
}
|
||||
await _folder.create(recursive: true);
|
||||
// Written aside and renamed, so no half-written file ever has the name.
|
||||
final partial = File('${file.path}.partial');
|
||||
await partial.writeAsBytes(bytes, flush: true);
|
||||
await partial.rename(file.path);
|
||||
}
|
||||
|
||||
@override
|
||||
String pathOf(String sha256) => p.join(_folder.path, checkedSha256(sha256));
|
||||
|
||||
@override
|
||||
Future<Set<String>> pendingUploads() async {
|
||||
if (!_uploads.existsSync()) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
await for (final entry in _uploads.list())
|
||||
if (entry is File && isSha256(p.basename(entry.path)))
|
||||
p.basename(entry.path),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markPending(String sha256) async {
|
||||
await _uploads.create(recursive: true);
|
||||
await File(
|
||||
p.join(_uploads.path, checkedSha256(sha256)),
|
||||
).writeAsBytes(const []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearPending(String sha256) async {
|
||||
try {
|
||||
await File(p.join(_uploads.path, checkedSha256(sha256))).delete();
|
||||
} on PathNotFoundException {
|
||||
// Not waiting.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [value] is a SHA-256 as files are named by: 64 lowercase hex
|
||||
/// digits.
|
||||
bool isSha256(String value) => _sha256.hasMatch(value);
|
||||
|
||||
/// [value], if it [isSha256]; throws an [ArgumentError] otherwise.
|
||||
String checkedSha256(String value) => isSha256(value)
|
||||
? value
|
||||
: throw ArgumentError.value(value, 'sha256', 'is no SHA-256 in hex');
|
||||
|
||||
final _sha256 = RegExp(r'^[0-9a-f]{64}$');
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// An asset row as stored: the file's id, SHA-256 (lowercase hex), media type
|
||||
/// and size, and its payload.
|
||||
typedef AssetRow = ({
|
||||
String id,
|
||||
String sha256,
|
||||
String mimeType,
|
||||
int byteSize,
|
||||
ByteData payload,
|
||||
});
|
||||
|
||||
/// The person's asset rows, one per file of their content (ADR 0004,
|
||||
/// rule 5); the files themselves are on the server and in the
|
||||
/// device's cache.
|
||||
abstract interface class AssetGateway() {
|
||||
/// The row [id]; null if there is none.
|
||||
Future<AssetRow?> byId(String id);
|
||||
|
||||
/// A row of the file with [sha256]; null if there is none.
|
||||
Future<AssetRow?> bySha256(String sha256);
|
||||
|
||||
/// Stores a new row and returns its id.
|
||||
Future<String> insert({
|
||||
required String sha256,
|
||||
required String mimeType,
|
||||
required int byteSize,
|
||||
required ByteData payload,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
|
||||
|
||||
// A file's content as its synced asset row stores it (see core/content/payload);
|
||||
// the hash, media type and size are columns of their own.
|
||||
//
|
||||
// Schema 1 holds the name the file came with.
|
||||
|
||||
/// The schema this app writes.
|
||||
const assetSchemaVersion = 1;
|
||||
|
||||
/// Encodes a file's [fileName] into its payload.
|
||||
ByteData encodeAssetPayload({required String fileName}) =>
|
||||
encodePayload(assetSchemaVersion, {'fileName': fileName});
|
||||
|
||||
/// Decodes a file's payload into the name it came with.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
String decodeAssetPayload(ByteData payload) {
|
||||
final json = decodePayload(payload, newestSchema: assetSchemaVersion);
|
||||
if (json case {'fileName': final String fileName}) {
|
||||
return fileName;
|
||||
}
|
||||
throw const FormatException('Not a file payload.');
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/model/stored_file.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/async/serial_queue.dart';
|
||||
|
||||
/// The files of the person's content (ADR 0004, rule 5): an asset row each,
|
||||
/// synced like all content, and the file itself on this device and the
|
||||
/// server, by its SHA-256.
|
||||
///
|
||||
/// A file added here is kept on this device and waits to be uploaded; the
|
||||
/// uploads run whenever `uploadTriggers` fires (after each sync, so the row
|
||||
/// is usually on the server before its file), one at a time, and what fails
|
||||
/// waits for the next time. A file this device lacks is downloaded when asked
|
||||
/// for, checked against its hash and kept.
|
||||
class AssetRepository(
|
||||
final AssetGateway _rows,
|
||||
final AssetServerGateway _server,
|
||||
final AssetFileCache _files, {
|
||||
Stream<void> uploadTriggers = const Stream.empty(),
|
||||
}) {
|
||||
this {
|
||||
_triggers = uploadTriggers.listen((_) => unawaited(uploadPending()));
|
||||
}
|
||||
|
||||
/// The largest file the server takes.
|
||||
static const maxBytes = 20 * 1024 * 1024;
|
||||
|
||||
late final StreamSubscription<void> _triggers;
|
||||
|
||||
/// Adding one file at a time, so a file added twice at once is one row.
|
||||
final _adding = SerialQueue();
|
||||
|
||||
/// The downloads under way, by hash, so a file asked for twice at once is
|
||||
/// downloaded once.
|
||||
final _downloads = <String, Future<Uint8List?>>{};
|
||||
|
||||
Future<void>? _uploading;
|
||||
var _uploadAgain = false;
|
||||
|
||||
/// Adds [bytes], a file named [fileName] of [mimeType], and returns the id
|
||||
/// of its asset row: the row of the same file if there is one, else a new
|
||||
/// one. Throws an [ArgumentError] for an empty file or one over [maxBytes].
|
||||
Future<String> add(
|
||||
Uint8List bytes, {
|
||||
required String fileName,
|
||||
required String mimeType,
|
||||
}) async {
|
||||
if (bytes.isEmpty || bytes.length > maxBytes) {
|
||||
throw ArgumentError.value(bytes.length, 'bytes', 'is out of range');
|
||||
}
|
||||
return await _adding.run(() async {
|
||||
final hash = sha256.convert(bytes).toString();
|
||||
await _files.write(hash, bytes);
|
||||
if (await _rows.bySha256(hash) case final row?) {
|
||||
return row.id;
|
||||
}
|
||||
final id = await _rows.insert(
|
||||
sha256: hash,
|
||||
mimeType: mimeType,
|
||||
byteSize: bytes.length,
|
||||
payload: encodeAssetPayload(fileName: fileName),
|
||||
);
|
||||
// Every new row asks the server, even for a file uploaded before: the
|
||||
// server may have pruned it once no row referred to it.
|
||||
await _files.markPending(hash);
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
/// The file of the asset row [id]: from this device, else downloaded.
|
||||
/// Null when it can't be had now: no such row, no connection, or not
|
||||
/// uploaded yet.
|
||||
Future<StoredFile?> fileOf(String id) async {
|
||||
final row = await _rows.byId(id);
|
||||
if (row == null || !isSha256(row.sha256)) {
|
||||
return null;
|
||||
}
|
||||
final bytes =
|
||||
await _files.read(row.sha256) ??
|
||||
await (_downloads[row.sha256] ??=
|
||||
_download(
|
||||
id,
|
||||
row.sha256,
|
||||
).whenComplete(() {
|
||||
_downloads.remove(row.sha256);
|
||||
}));
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
String fileName;
|
||||
try {
|
||||
fileName = decodeAssetPayload(row.payload);
|
||||
} on FormatException {
|
||||
fileName = '';
|
||||
}
|
||||
return StoredFile(
|
||||
bytes: bytes,
|
||||
mimeType: row.mimeType,
|
||||
fileName: fileName,
|
||||
path: _files.pathOf(row.sha256),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _download(String id, String hash) async {
|
||||
try {
|
||||
final bytes = await _server.download(id);
|
||||
if (bytes == null || sha256.convert(bytes).toString() != hash) {
|
||||
return null;
|
||||
}
|
||||
await _files.write(hash, bytes);
|
||||
return bytes;
|
||||
} on Object catch (error) {
|
||||
log('Downloading a file failed', name: 'Assets', error: error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads the files that wait; what fails waits for the next time. One
|
||||
/// run at a time: a call during a run runs once more after it.
|
||||
Future<void> uploadPending() {
|
||||
if (_uploading case final running?) {
|
||||
_uploadAgain = true;
|
||||
return running;
|
||||
}
|
||||
return _uploading = _uploadWhileAsked().whenComplete(
|
||||
() => _uploading = null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _uploadWhileAsked() async {
|
||||
do {
|
||||
_uploadAgain = false;
|
||||
for (final hash in await _files.pendingUploads()) {
|
||||
try {
|
||||
if (!await _upload(hash)) {
|
||||
continue;
|
||||
}
|
||||
await _files.clearPending(hash);
|
||||
} on Object catch (error) {
|
||||
// Without a connection the others fail too: next time.
|
||||
log('Uploading a file failed', name: 'Assets', error: error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} while (_uploadAgain);
|
||||
}
|
||||
|
||||
/// Uploads the file with [hash]; whether it no longer waits.
|
||||
Future<bool> _upload(String hash) async {
|
||||
final bytes = await _files.read(hash);
|
||||
if (bytes == null) {
|
||||
// Gone from this device: nothing to upload, ever.
|
||||
return true;
|
||||
}
|
||||
final description = await _server.startUpload(hash, bytes.length);
|
||||
if (description == null) {
|
||||
return true;
|
||||
}
|
||||
return await _server.upload(description, bytes) &&
|
||||
await _server.finishUpload(hash);
|
||||
}
|
||||
|
||||
/// Stops uploading; an upload under way finishes first.
|
||||
Future<void> dispose() async {
|
||||
await _triggers.cancel();
|
||||
_uploadAgain = false;
|
||||
await _uploading;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
/// The server's copy of the person's files.
|
||||
abstract interface class AssetServerGateway() {
|
||||
/// How to upload the file with [sha256] of [byteSize] bytes; null when the
|
||||
/// server has it already.
|
||||
Future<String?> startUpload(String sha256, int byteSize);
|
||||
|
||||
/// Uploads [bytes] as [description] says; whether that worked.
|
||||
Future<bool> upload(String description, Uint8List bytes);
|
||||
|
||||
/// Completes the upload of the file with [sha256]; whether the server keeps
|
||||
/// it, which it does only if its content has that hash.
|
||||
Future<bool> finishUpload(String sha256);
|
||||
|
||||
/// The file of the asset [id]; null when the server has none for this
|
||||
/// person (not uploaded yet, or not theirs).
|
||||
Future<Uint8List?> download(String id);
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_server_gateway.dart';
|
||||
|
||||
/// [AssetServerGateway] on the server's asset endpoint, uploading through
|
||||
/// Serverpod's own upload route.
|
||||
class ServerpodAssetServerGateway(final protocol.Client _client)
|
||||
implements AssetServerGateway {
|
||||
@override
|
||||
Future<String?> startUpload(String sha256, int byteSize) =>
|
||||
_client.asset.startUpload(sha256, byteSize);
|
||||
|
||||
@override
|
||||
Future<bool> upload(String description, Uint8List bytes) =>
|
||||
protocol.FileUploader(description)
|
||||
.uploadByteData(ByteData.sublistView(bytes));
|
||||
|
||||
@override
|
||||
Future<bool> finishUpload(String sha256) =>
|
||||
_client.asset.finishUpload(sha256);
|
||||
|
||||
@override
|
||||
Future<Uint8List?> download(String id) async {
|
||||
try {
|
||||
return Uint8List.sublistView(
|
||||
await _client.asset.download(protocol.UuidValue.withValidation(id)),
|
||||
);
|
||||
} on protocol.NotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
|
||||
/// Plays the sounds in content, one at a time.
|
||||
abstract interface class SoundPlayer() {
|
||||
/// The file playing now, or null; fires whenever that changes.
|
||||
Stream<String?> get playing;
|
||||
|
||||
/// Plays the sound in the file at [path] of [mimeType]; a sound playing
|
||||
/// stops first.
|
||||
Future<void> play(String path, {required String mimeType});
|
||||
|
||||
/// Stops the sound playing, if any.
|
||||
Future<void> stop();
|
||||
|
||||
/// Stops and lets go of the device's player.
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
/// [SoundPlayer] on `audioplayers`: one player for the whole app.
|
||||
class AudioplayersSoundPlayer() implements SoundPlayer {
|
||||
final _player = AudioPlayer();
|
||||
final _playing = StreamController<String?>.broadcast();
|
||||
String? _path;
|
||||
StreamSubscription<PlayerState>? _states;
|
||||
|
||||
@override
|
||||
Stream<String?> get playing => _playing.stream;
|
||||
|
||||
@override
|
||||
Future<void> play(String path, {required String mimeType}) async {
|
||||
_states ??= _player.onPlayerStateChanged.listen((state) {
|
||||
if (state != PlayerState.playing) {
|
||||
_set(null);
|
||||
}
|
||||
});
|
||||
await _player.stop();
|
||||
_set(path);
|
||||
try {
|
||||
await _player.play(DeviceFileSource(path, mimeType: mimeType));
|
||||
} on Object {
|
||||
_set(null);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await _player.stop();
|
||||
_set(null);
|
||||
}
|
||||
|
||||
void _set(String? path) {
|
||||
if (path != _path) {
|
||||
_path = path;
|
||||
_playing.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _states?.cancel();
|
||||
await _player.dispose();
|
||||
await _playing.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:serverpod_offline_sync_client/offline_sync.dart';
|
||||
|
||||
/// [AssetGateway] on the person's local database, which the sync engine keeps
|
||||
/// in step with their other devices.
|
||||
class SyncAssetGateway(
|
||||
final OfflineSyncDatabaseSession _db,
|
||||
final SyncService _sync,
|
||||
) implements AssetGateway {
|
||||
@override
|
||||
Future<AssetRow?> byId(String id) async {
|
||||
final protocol.UuidValue uuid;
|
||||
try {
|
||||
uuid = protocol.UuidValue.withValidation(id);
|
||||
} on FormatException {
|
||||
// Content can name anything; a name that is no id names no row.
|
||||
return null;
|
||||
}
|
||||
return _row(await protocol.Asset.db.findById(_db, uuid));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AssetRow?> bySha256(String sha256) async => _row(
|
||||
await protocol.Asset.db.findFirstRow(
|
||||
_db,
|
||||
where: (t) => t.sha256.equals(sha256),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<String> insert({
|
||||
required String sha256,
|
||||
required String mimeType,
|
||||
required int byteSize,
|
||||
required ByteData payload,
|
||||
}) async {
|
||||
final asset = await protocol.Asset.db.insertRow(
|
||||
_db,
|
||||
protocol.Asset(
|
||||
sha256: sha256,
|
||||
mimeType: mimeType,
|
||||
byteSize: byteSize,
|
||||
payload: payload,
|
||||
),
|
||||
);
|
||||
_sync.requestSync();
|
||||
return asset.id!.uuid;
|
||||
}
|
||||
|
||||
AssetRow? _row(protocol.Asset? asset) => asset == null
|
||||
? null
|
||||
: (
|
||||
id: asset.id!.uuid,
|
||||
sha256: asset.sha256,
|
||||
mimeType: asset.mimeType,
|
||||
byteSize: asset.byteSize,
|
||||
payload: asset.payload,
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// A file of the person's content (an image or a sound), on this device and
|
||||
/// ready to show or play.
|
||||
class const StoredFile({
|
||||
/// Its content.
|
||||
required final Uint8List bytes,
|
||||
|
||||
/// Its media type, e.g. 'image/png' or 'audio/mpeg'.
|
||||
required final String mimeType,
|
||||
|
||||
/// The name it came with.
|
||||
required final String fileName,
|
||||
|
||||
/// Where it is on this device, for players that read files.
|
||||
required final String path,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [bytes, mimeType, fileName, path];
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
|
||||
/// The image in the person's file [assetId], as [assets] has it: from this
|
||||
/// device, else downloaded.
|
||||
///
|
||||
/// Flutter's image cache keeps it by the file and the person's repository, so
|
||||
/// nothing of one person's shows for another's. One that can't be had now is
|
||||
/// not kept, so showing it again asks again.
|
||||
@immutable
|
||||
class const StoredFileImage(
|
||||
/// The person's files.
|
||||
final AssetRepository assets,
|
||||
|
||||
/// The image's asset row.
|
||||
final String assetId,
|
||||
) extends ImageProvider<StoredFileImage> {
|
||||
@override
|
||||
Future<StoredFileImage> obtainKey(ImageConfiguration configuration) =>
|
||||
SynchronousFuture(this);
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
StoredFileImage key,
|
||||
ImageDecoderCallback decode,
|
||||
) => MultiFrameImageStreamCompleter(codec: _load(key, decode), scale: 1);
|
||||
|
||||
Future<Codec> _load(StoredFileImage key, ImageDecoderCallback decode) async {
|
||||
final file = await assets.fileOf(assetId);
|
||||
if (file == null) {
|
||||
scheduleMicrotask(() => PaintingBinding.instance.imageCache.evict(key));
|
||||
throw StateError('The file $assetId is not available.');
|
||||
}
|
||||
return await decode(await ImmutableBuffer.fromUint8List(file.bytes));
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is StoredFileImage &&
|
||||
identical(other.assets, assets) &&
|
||||
other.assetId == assetId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(identityHashCode(assets), assetId);
|
||||
}
|
||||
|
|
@ -88,6 +88,39 @@ final class RichContent(
|
|||
return blocks.where((block) => block.isNotEmpty).join('\n');
|
||||
}
|
||||
|
||||
/// Whether it holds nothing: no text, no image and no sound.
|
||||
bool get isBlank => plainText.trim().isEmpty && !hasImages && !hasSounds;
|
||||
|
||||
/// Whether it shows an image, the person's or another.
|
||||
bool get hasImages => _hasBlock('image');
|
||||
|
||||
/// Whether it plays a sound.
|
||||
bool get hasSounds => _hasBlock('audio');
|
||||
|
||||
/// The person's files it shows or plays, by their asset rows' ids.
|
||||
Set<String> get assetIds => {
|
||||
for (final node in _nodes)
|
||||
if (node case {'data': {'url': final url}}) ?assetIdOf(url),
|
||||
};
|
||||
|
||||
bool _hasBlock(String type) => _nodes.any((node) => node['type'] == type);
|
||||
|
||||
/// Every block, depth first.
|
||||
Iterable<Map<String, Object?>> get _nodes sync* {
|
||||
Iterable<Map<String, Object?>> visit(Object? node) sync* {
|
||||
if (node is Map<String, Object?>) {
|
||||
yield node;
|
||||
if (node case {'children': final List<Object?> children}) {
|
||||
for (final child in children) {
|
||||
yield* visit(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield* visit(editorJson['document']);
|
||||
}
|
||||
|
||||
/// The JSON stored in the content payload.
|
||||
Map<String, Object?> toJson() => {
|
||||
'formatVersion': formatVersion,
|
||||
|
|
@ -95,6 +128,20 @@ final class RichContent(
|
|||
};
|
||||
}
|
||||
|
||||
/// The URL naming the person's file [assetId] in content: an image's or a
|
||||
/// sound's (ADR 0004, rule 5).
|
||||
String assetUrl(String assetId) => '$_assetScheme$assetId';
|
||||
|
||||
/// The asset row [url] names, if it names one.
|
||||
String? assetIdOf(Object? url) =>
|
||||
url is String &&
|
||||
url.startsWith(_assetScheme) &&
|
||||
url.length > _assetScheme.length
|
||||
? url.substring(_assetScheme.length)
|
||||
: null;
|
||||
|
||||
const _assetScheme = 'asset:';
|
||||
|
||||
Map<String, Object?> _unmodifiableMap(Map<String, Object?> map) =>
|
||||
Map.unmodifiable({
|
||||
for (final MapEntry(:key, :value) in map.entries)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@ import 'dart:io';
|
|||
|
||||
import 'package:halkyon_learn_client/halkyon_learn_client.dart'
|
||||
show Client, UuidValue;
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_file_cache.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/serverpod_asset_server_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sound_player.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/data/sync_asset_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_dependencies.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_folders.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/files/file_picker.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/sync/sync_service.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/about/data/about_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/about/data/serverpod_about_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_package_reader.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/auth_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/data/serverpod_auth_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart';
|
||||
|
|
@ -49,6 +56,15 @@ class Dependencies({
|
|||
|
||||
/// The region maps the app bundles, for map questions.
|
||||
required final GeoMapRepository maps,
|
||||
|
||||
/// Plays the sounds in content.
|
||||
required final SoundPlayer sounds,
|
||||
|
||||
/// Lets the person pick files to import.
|
||||
required final FilePicker files,
|
||||
|
||||
/// Reads Anki packages to import.
|
||||
required final AnkiPackageReader ankiPackages,
|
||||
final Future<void> Function()? _onDispose,
|
||||
}) {
|
||||
/// Builds the production graph around one Serverpod client.
|
||||
|
|
@ -73,10 +89,14 @@ class Dependencies({
|
|||
// person's database has closed, so their folder can go. If it doesn't
|
||||
// close, the app goes on after a while and the folder goes next time.
|
||||
var previousClosed = Future<void>.value();
|
||||
final sounds = AudioplayersSoundPlayer();
|
||||
return Dependencies(
|
||||
auth: AuthRepository(authGateway),
|
||||
about: AboutRepository(ServerpodAboutGateway(client)),
|
||||
maps: GeoMapRepository(),
|
||||
sounds: sounds,
|
||||
files: FileSelectorFilePicker(),
|
||||
ankiPackages: IsolateAnkiPackageReader(),
|
||||
openPerson: (person) async {
|
||||
await previousClosed.timeout(
|
||||
const Duration(seconds: 10),
|
||||
|
|
@ -98,6 +118,7 @@ class Dependencies({
|
|||
}
|
||||
},
|
||||
onDispose: () async {
|
||||
await sounds.dispose();
|
||||
await authGateway.dispose();
|
||||
client.close();
|
||||
},
|
||||
|
|
@ -139,6 +160,12 @@ Future<PersonDependencies> _openPerson(
|
|||
final settings = SettingsRepository(SyncSettingsGateway(db, sync));
|
||||
final reviews = ReviewRepository(SyncReviewGateway(db, sync));
|
||||
final deletions = StudysetDeletions(studysets, cards);
|
||||
final assets = AssetRepository(
|
||||
SyncAssetGateway(db, sync),
|
||||
ServerpodAssetServerGateway(client),
|
||||
DirectoryAssetFileCache(Directory(p.join(folder.path, 'files'))),
|
||||
uploadTriggers: sync.synced,
|
||||
);
|
||||
|
||||
return PersonDependencies(
|
||||
studysets: studysets,
|
||||
|
|
@ -146,6 +173,7 @@ Future<PersonDependencies> _openPerson(
|
|||
reviews: reviews,
|
||||
tags: tags,
|
||||
settings: settings,
|
||||
assets: assets,
|
||||
deletions: deletions,
|
||||
onDispose: () async {
|
||||
try {
|
||||
|
|
@ -157,6 +185,7 @@ Future<PersonDependencies> _openPerson(
|
|||
await settings.dispose();
|
||||
await reviews.dispose();
|
||||
await cards.dispose();
|
||||
await assets.dispose();
|
||||
await sync.dispose();
|
||||
await db.close();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/settings/data/settings_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
|
||||
|
|
@ -26,6 +27,9 @@ class PersonDependencies({
|
|||
/// The person's settings, which follow them to every device.
|
||||
required final SettingsRepository settings,
|
||||
|
||||
/// The files of the person's content: images and sounds.
|
||||
required final AssetRepository assets,
|
||||
|
||||
/// The studyset deletions that can still be undone.
|
||||
required final StudysetDeletions deletions,
|
||||
final Future<void> Function()? _onDispose,
|
||||
|
|
|
|||
301
halkyon_learn_flutter/lib/src/core/editor/file_blocks.dart
Normal file
301
halkyon_learn_flutter/lib/src/core/editor/file_blocks.dart
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/bloc/sound_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/asset/widget/stored_file_image.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
// Images and sounds in rich text. A block names a file of the person's by its
|
||||
// URL (`assetUrl`): an image is the editor's own `image` block, a sound an
|
||||
// `audio` block of ours (both known to `RichContent`). Any other URL is never
|
||||
// fetched: content from elsewhere could otherwise make every device that
|
||||
// shows it call a server of its choice.
|
||||
|
||||
/// The block type of a sound.
|
||||
const audioBlockType = 'audio';
|
||||
|
||||
/// The block attribute holding a file's URL, in images and sounds alike.
|
||||
const fileUrlKey = ImageBlockKeys.url;
|
||||
|
||||
/// A block showing the image in the person's file [assetId].
|
||||
Node fileImageNode(String assetId) => imageNode(url: assetUrl(assetId));
|
||||
|
||||
/// A block playing the sound in the person's file [assetId].
|
||||
Node audioNode(String assetId) =>
|
||||
Node(type: audioBlockType, attributes: {fileUrlKey: assetUrl(assetId)});
|
||||
|
||||
/// [content] in one line for lists: its first line of text, else what it
|
||||
/// shows or plays; empty for blank content.
|
||||
String previewOf(RichContent content, AppLocalizations l10n) {
|
||||
final text = content.plainText.trim();
|
||||
if (text.isNotEmpty) {
|
||||
return text.split('\n').first;
|
||||
}
|
||||
return content.hasImages
|
||||
? l10n.fileImage
|
||||
: content.hasSounds
|
||||
? l10n.fileSound
|
||||
: '';
|
||||
}
|
||||
|
||||
/// The editor's blocks, with images and sounds shown as above.
|
||||
final Map<String, BlockComponentBuilder> fileBlockBuilders = {
|
||||
...standardBlockComponentBuilderMap,
|
||||
ImageBlockKeys.type: _FileBlockBuilder(_FileKind.image),
|
||||
audioBlockType: _FileBlockBuilder(_FileKind.sound),
|
||||
};
|
||||
|
||||
enum _FileKind() {
|
||||
image,
|
||||
sound,
|
||||
}
|
||||
|
||||
class _FileBlockBuilder(final _FileKind _kind) extends BlockComponentBuilder {
|
||||
@override
|
||||
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
|
||||
final node = blockComponentContext.node;
|
||||
return _FileBlock(
|
||||
key: node.key,
|
||||
node: node,
|
||||
kind: _kind,
|
||||
configuration: configuration,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
BlockComponentValidate get validate =>
|
||||
(node) => node.delta == null && node.children.isEmpty;
|
||||
}
|
||||
|
||||
/// A file in the document: selected, copied and deleted as a whole, like the
|
||||
/// editor's own image block.
|
||||
class const _FileBlock({
|
||||
required super.node,
|
||||
required final _FileKind kind,
|
||||
required super.configuration,
|
||||
super.key,
|
||||
}) extends BlockComponentStatefulWidget {
|
||||
@override
|
||||
State<_FileBlock> createState() => _FileBlockState();
|
||||
}
|
||||
|
||||
class _FileBlockState()
|
||||
extends State<_FileBlock>
|
||||
with SelectableMixin, BlockComponentConfigurable {
|
||||
final _contentKey = GlobalKey();
|
||||
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
late final _editorState = context.read<EditorState>();
|
||||
|
||||
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assetId = assetIdOf(node.attributes[fileUrlKey]);
|
||||
final content = switch ((widget.kind, assetId)) {
|
||||
(_, null) => _Unavailable(
|
||||
icon: LucideIcons.imageOff,
|
||||
text: AppLocalizations.of(context).fileFromElsewhere,
|
||||
),
|
||||
(_FileKind.image, final id?) => _FileImage(
|
||||
assetId: id,
|
||||
maxWidth: (node.attributes[ImageBlockKeys.width] as num?)?.toDouble(),
|
||||
),
|
||||
(_FileKind.sound, final id?) => _Sound(assetId: id),
|
||||
};
|
||||
return Padding(
|
||||
padding: margin,
|
||||
child: BlockSelectionContainer(
|
||||
node: node,
|
||||
delegate: this,
|
||||
listenable: _editorState.selectionNotifier,
|
||||
remoteSelection: _editorState.remoteSelections,
|
||||
blockColor: _editorState.editorStyle.selectionColor,
|
||||
supportTypes: const [BlockSelectionType.block],
|
||||
child: Padding(
|
||||
key: _contentKey,
|
||||
padding: padding,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: node.path);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: node.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.cover;
|
||||
|
||||
@override
|
||||
Rect getBlockRect({bool shiftWithBaseOffset = false}) {
|
||||
final box = _contentKey.currentContext?.findRenderObject();
|
||||
return box is RenderBox ? Offset.zero & box.size : Rect.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(
|
||||
Position position, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final size = _renderBox?.size;
|
||||
return size == null
|
||||
? null
|
||||
: Rect.fromLTWH(-size.width / 2, 0, size.width, size.height);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(
|
||||
Selection selection, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final parent = _renderBox;
|
||||
if (parent == null) {
|
||||
return [];
|
||||
}
|
||||
final box = _contentKey.currentContext?.findRenderObject();
|
||||
return [
|
||||
if (box is RenderBox)
|
||||
box.localToGlobal(Offset.zero, ancestor: parent) & box.size
|
||||
else
|
||||
Offset.zero & parent.size,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) =>
|
||||
Selection.single(path: node.path, startOffset: 0, endOffset: 1);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
|
||||
_renderBox!.localToGlobal(offset);
|
||||
}
|
||||
|
||||
/// An image of the person's, at most as wide as the text and a little taller
|
||||
/// than a card's face needs.
|
||||
class const _FileImage({
|
||||
required final String assetId,
|
||||
final double? maxWidth,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth ?? double.infinity,
|
||||
maxHeight: 360,
|
||||
),
|
||||
child: Image(
|
||||
image: StoredFileImage(PersonScope.of(context).assets, assetId),
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: AppLocalizations.of(context).fileImage,
|
||||
errorBuilder: (context, error, stackTrace) => _Unavailable(
|
||||
icon: LucideIcons.imageOff,
|
||||
text: AppLocalizations.of(context).fileImageUnavailable,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A sound of the person's: a button that plays and stops it.
|
||||
class const _Sound({required final String assetId}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) => SoundBloc(
|
||||
PersonScope.of(context).assets,
|
||||
DependenciesScope.of(context).sounds,
|
||||
assetId,
|
||||
),
|
||||
child: const _SoundButton(),
|
||||
);
|
||||
}
|
||||
|
||||
class const _SoundButton() extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.watch<SoundBloc>().state;
|
||||
if (state == SoundState.unavailable) {
|
||||
return _Unavailable(
|
||||
icon: LucideIcons.volumeOff,
|
||||
text: l10n.fileSoundUnavailable,
|
||||
onRetry: () => context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: () =>
|
||||
context.read<SoundBloc>().add(const SoundEventToggled()),
|
||||
icon: switch (state) {
|
||||
SoundState.loading => const SizedBox.square(
|
||||
dimension: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
SoundState.playing => const Icon(LucideIcons.square),
|
||||
SoundState.idle || SoundState.unavailable => const Icon(
|
||||
LucideIcons.play,
|
||||
),
|
||||
},
|
||||
label: Text(
|
||||
state == SoundState.playing ? l10n.fileStopSound : l10n.filePlaySound,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// In place of a file that can't be shown.
|
||||
class const _Unavailable({
|
||||
required final IconData icon,
|
||||
required final String text,
|
||||
final VoidCallback? onRetry,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = theme.colorScheme.onSurfaceVariant;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
if (onRetry != null)
|
||||
TextButton(
|
||||
onPressed: onRetry,
|
||||
child: Text(AppLocalizations.of(context).commonTryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_style.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
|
||||
|
||||
/// Edits [initialContent] and reports each change as new [RichContent].
|
||||
|
|
@ -58,6 +59,7 @@ class _RichContentEditorState() extends State<RichContentEditor> {
|
|||
Widget build(BuildContext context) => AppFlowyEditor(
|
||||
editorState: _editorState,
|
||||
editorStyle: editorStyleOf(context),
|
||||
blockComponentBuilders: fileBlockBuilders,
|
||||
autoFocus: widget.autofocus,
|
||||
focusNode: widget.focusNode,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/editor_style.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/file_blocks.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
|
||||
|
||||
/// Shows [content] read-only: the one renderer for rich text wherever it is
|
||||
|
|
@ -46,6 +47,7 @@ class _RichContentViewState() extends State<RichContentView> {
|
|||
key: ObjectKey(_editorState),
|
||||
editorState: _editorState,
|
||||
editorStyle: editorStyleOf(context),
|
||||
blockComponentBuilders: fileBlockBuilders,
|
||||
editable: false,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue