Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
name: Community checks
on: [push, pull_request]
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: community-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
strategy:
fail-fast: false
matrix:
flutter: ['3.44.0', '3.47.2']
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0
with:
channel: stable
flutter-version: ${{ matrix.flutter }}
cache: true
- run: python3 scripts/check_public_tree.py
- run: flutter pub get
- run: flutter analyze
- run: flutter test
- run: flutter build apk --debug
if: matrix.flutter == '3.47.2'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: matrix.flutter == '3.47.2'
with:
name: wordai-android-debug
path: build/app/outputs/flutter-apk/app-debug.apk
if-no-files-found: error
retention-days: 14
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Changelog

## 0.1.1

- Keep imported content and learning progress when the home screen refreshes.
Bundled samples now fill missing meanings without overwriting existing records.
- Accept either one S6 entry or an array. Validate the entire file before writing,
enforcing the actual byte limit and rejecting entries without learning content.
- Preserve committed import counts when storage or a subsequent refresh fails.
A successful retry clears the loading error without losing the import result.
- Apply one speech-download deadline across headers and body. Timeout aborts the
request, cancels body reading and closes the client owned by that request.
- Validate on Flutter 3.44.0 and 3.47.2. The committed dependency lock matches the
minimum Flutter version; newer SDKs resolve their own required framework pins.
- Publish the Android debug build as a CI artifact for development verification.

## 0.1.0

- Initial community learning core, original sample words and S6 imports.
- Local SQLite progress, review preparation, pronunciation and optional speech gateway.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ flutter run
Android, iOS and macOS projects are included. Device signing for iOS/macOS is your
own configuration; no signing team, certificate or production backend is bundled.
The [CI workflow](.github/workflows/checks.yml) runs analysis, tests and an Android
debug build. Platform support beyond those checks should be verified on the target device.
debug build. Analysis and tests run on Flutter 3.44.0 and 3.47.2; the latter also
produces a downloadable debug APK in the Actions run. This is a development build,
not a signed store release. Platform support beyond those checks should be verified on the target device.

## What the app does

Expand All @@ -40,6 +42,8 @@ debug build. Platform support beyond those checks should be verified on the targ
| Problem | Implementation | Regression tests |
|---|---|---|
| Persist meaning-level learning progress | [learning_repository.dart](lib/services/learning_repository.dart) | [learning_repository_test.dart](test/learning_repository_test.dart) |
| Preserve imported content across refreshes | [dictionary_import.dart](lib/services/dictionary_import.dart) | [home import](test/home_import_widget_test.dart), [dictionary validation](test/dictionary_import_test.dart) |
| Bound a slow speech download | [community_gateway.dart](lib/services/community_gateway.dart) | [gateway lifecycle](test/community_gateway_test.dart) |
| Prepare a usable review from available content | [review_preparation.dart](lib/services/review_preparation.dart) | [review_preparation_test.dart](test/review_preparation_test.dart) |
| Coordinate pronunciation during review | [review_pronunciation.dart](lib/services/review_pronunciation.dart) | [review_pronunciation_test.dart](test/review_pronunciation_test.dart), [playback arbitration](test/tts_playback_arbiter_test.dart) |
| Keep review states explicit | [review availability tests](test/review_availability_widget_test.dart) | [loading-state tests](test/review_loading_widget_test.dart) |
Expand All @@ -56,11 +60,15 @@ Choose **Import JSON** on the home screen. A file may contain one entry or an ar
using the S6 schema in [wordai_dossier.dart](lib/services/wordai_dossier.dart).
See [examples/words.json](examples/words.json).

Files are validated before writes, with a limit of 10 MB and 20,000 entries.
Files are validated before writes, with a limit of 10 MiB actually read and 20,000
entries. Empty files, empty arrays and lookup abstentions are not importable.
Reimporting an entry updates its content while preserving learning progress. A
storage failure may leave earlier entries imported; retrying is supported. The
import is not represented as an all-or-nothing database transaction.

Refreshing the home screen seeds only missing sample meanings. It does not
overwrite an imported replacement or reset its learning state.

Import only content you have permission to use. Production third-party dictionaries
and audio collections are not distributed here.

Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ python3 scripts/check_public_tree.py

## 导入词条

首页选择“导入 JSON”。文件可以是一个词条对象或数组,采用 `lib/services/wordai_dossier.dart` 定义的 S6 schema。整个文件先验证,再写入本地数据库;输入上限 10 MB / 20,000 条。重复导入更新内容并保留学习进度。导入过程中发生存储错误时,已完成的词条会保留,可以重试。
首页选择“导入 JSON”。文件可以是一个词条对象或数组,采用 `lib/services/wordai_dossier.dart` 定义的 S6 schema。整个文件先验证,再写入本地数据库;实际读取上限 10 MiB / 20,000 条,空文件、空数组和没有学习内容的查词结果不能导入。重复导入更新内容并保留学习进度。导入过程中发生存储错误时,已完成的词条会保留,可以重试。

首页刷新只补充缺失的样例词义,不覆盖用户导入的内容或已有学习进度。CI 使用 Flutter 3.44.0 和 3.47.2 分别运行分析与测试,后者同时生成可下载的 Android debug APK;该产物用于开发验证,尚非商店签名发行版。

示例:`examples/words.json`。请确认导入内容的分发与使用许可。生产环境的第三方词库和音频没有随本仓库分发。

Expand Down
3 changes: 3 additions & 0 deletions docs/speech-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ No server is bundled or configured. An operator may implement `POST /tts` relati

- Request: UTF-8 text, `Content-Type: text/plain; charset=utf-8`.
- Response: status 200 and valid MP3 bytes, at most 900,000 bytes. Redirects are rejected.
- One ten-second deadline covers connection, response headers and the complete
body. Receiving more chunks does not extend it. Timeout aborts the request,
cancels body reading and closes the client dedicated to that request.
- Failed/invalid responses fall back to system speech where available. Successful audio is cached locally.
- The client sends no provider credentials. Keep provider keys on the server, outside source control.
- Operators must add input limits, rate limits, abuse prevention and appropriate access control; this minimal client does not implement user authentication. Do not expose an unrestricted paid provider proxy.
Expand Down
78 changes: 46 additions & 32 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
Expand All @@ -9,7 +8,7 @@ import 'flutter_flow/flutter_flow_theme.dart';
import 'flutter_flow/internationalization.dart';
import 'pages/flash_cards/flash_cards_widget.dart';
import 'services/learning_repository.dart';
import 'services/wordai_dossier.dart';
import 'services/dictionary_import.dart';
import 'sample_words.dart';

Future<void> main() async {
Expand All @@ -23,7 +22,13 @@ Future<void> main() async {
}

class CommunityApp extends StatefulWidget {
const CommunityApp({super.key});
const CommunityApp({
super.key,
@visibleForTesting this.homeRepository,
@visibleForTesting this.selectDictionary,
});
final LearningRepository? homeRepository;
final Future<XFile?> Function()? selectDictionary;
@override
State<CommunityApp> createState() => _CommunityAppState();
}
Expand All @@ -33,8 +38,10 @@ class _CommunityAppState extends State<CommunityApp> {
late final GoRouter _router = GoRouter(routes: [
GoRoute(
path: '/',
builder: (_, __) =>
_Home(onLocale: (locale) => setState(() => _locale = locale))),
builder: (_, __) => _Home(
repository: widget.homeRepository ?? LearningRepository.instance,
selectDictionary: widget.selectDictionary,
onLocale: (locale) => setState(() => _locale = locale))),
GoRoute(
path: '/review',
builder: (_, state) => FlashCardsWidget(
Expand Down Expand Up @@ -75,17 +82,23 @@ class _CommunityAppState extends State<CommunityApp> {
}

class _Home extends StatefulWidget {
const _Home({required this.onLocale});
const _Home(
{required this.onLocale,
required this.repository,
this.selectDictionary});
final ValueChanged<Locale> onLocale;
final LearningRepository repository;
final Future<XFile?> Function()? selectDictionary;
@override
State<_Home> createState() => _HomeState();
}

class _HomeState extends State<_Home> {
final _repo = LearningRepository.instance;
LearningRepository get _repo => widget.repository;
List<String> _words = [];
bool _busy = true;
String? _message;
String? _importMessage;
String? _loadError;
int _pending = 0;
@override
void initState() {
Expand All @@ -96,9 +109,10 @@ class _HomeState extends State<_Home> {
String t(String en, String zh) =>
Localizations.localeOf(context).languageCode == 'zh' ? zh : en;
Future<void> _load() async {
if (mounted) setState(() => _busy = true);
try {
for (final d in sampleWords()) {
await _repo.registerDossier('local', d);
await _repo.registerMissingDossier('local', d);
}
final words = (await _repo.registeredQueries('local')).toList()..sort();
final pending = await _repo.reviewableWordCount('local');
Expand All @@ -107,13 +121,14 @@ class _HomeState extends State<_Home> {
_words = words;
_pending = pending;
_busy = false;
_loadError = null;
});
}
} catch (_) {
if (mounted) {
setState(() {
_busy = false;
_message = 'Local storage is unavailable. Please retry.';
_loadError = 'Local storage is unavailable. Please retry.';
});
}
}
Expand All @@ -122,38 +137,34 @@ class _HomeState extends State<_Home> {
Future<void> _import() async {
setState(() {
_busy = true;
_message = null;
_importMessage = null;
});
var imported = 0;
try {
const type = XTypeGroup(
label: 'WordAI dictionary',
extensions: ['json'],
uniformTypeIdentifiers: ['public.json']);
final file = await openFile(acceptedTypeGroups: [type]);
final file = await (widget.selectDictionary?.call() ??
openFile(acceptedTypeGroups: [type]));
if (file == null) return;
if (await file.length() > 10 * 1024 * 1024) {
throw const FormatException('File exceeds 10 MB.');
}
final decoded = jsonDecode(await file.readAsString());
if (decoded is! List || decoded.length > 20000) {
throw const FormatException('Expected up to 20,000 S6 entries.');
imported = await importDictionary(file.openRead(),
repository: _repo,
uid: 'local',
onImported: (count) => imported = count);
if (mounted) {
setState(() => _importMessage =
'Imported $imported ${imported == 1 ? 'entry' : 'entries'}.');
}
final entries = decoded.map((row) {
if (row is! Map<String, dynamic> || row['query'] is! String) {
throw const FormatException('Invalid dictionary entry.');
}
return WordAiDossier.fromJson(row, expectedQuery: row['query']);
}).toList();
for (final entry in entries) {
await _repo.registerDossier('local', entry);
imported++;
} on DictionaryImportException catch (error) {
if (mounted) {
setState(() =>
_importMessage = '${error.message} No entries were imported.');
}
if (mounted) setState(() => _message = 'Imported $imported entries.');
} catch (_) {
if (mounted) {
setState(() => _message =
'Import stopped after $imported entries. Check the S6 format and available storage; existing progress is retained.');
setState(() => _importMessage =
'Import stopped after $imported ${imported == 1 ? 'entry' : 'entries'}. Check the file and available storage; existing progress is retained.');
}
} finally {
await _load();
Expand Down Expand Up @@ -218,10 +229,13 @@ class _HomeState extends State<_Home> {
tooltip: t('Retry', '重试'),
icon: const Icon(Icons.refresh)),
]),
if (_message != null)
for (final message in [
_importMessage,
_loadError
].whereType<String>())
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_message!)),
child: Text(message)),
])),
if (_busy) const LinearProgressIndicator(),
Expanded(
Expand Down
71 changes: 59 additions & 12 deletions lib/services/community_gateway.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,22 @@ class CommunityGatewayException implements Exception {
/// No hosted service is configured or contacted by default. Provider secrets
/// belong on the operator's server, never in this client or a dart-define.
class CommunityGateway {
CommunityGateway(
{this.baseUrl = const String.fromEnvironment('WORD_AI_API_BASE_URL')});
CommunityGateway({
this.baseUrl = const String.fromEnvironment('WORD_AI_API_BASE_URL'),
this.requestTimeout = const Duration(seconds: 10),
http.Client Function()? clientFactory,
}) : _clientFactory = clientFactory ?? http.Client.new {
if (requestTimeout <= Duration.zero ||
requestTimeout > const Duration(seconds: 10)) {
throw ArgumentError.value(requestTimeout, 'requestTimeout',
'Must be positive and at most ten seconds.');
}
}
static final instance = CommunityGateway();
final String baseUrl;
final Duration requestTimeout;
// A fresh client belongs to one request and is always closed on completion.
final http.Client Function() _clientFactory;

Uri get speechEndpoint {
final base = Uri.tryParse(baseUrl);
Expand All @@ -35,33 +47,68 @@ class CommunityGateway {

Future<Uint8List> synthesizeSpeech(String text) async {
final uri = speechEndpoint;
final client = http.Client();
try {
final request = http.Request('POST', uri)
..followRedirects = false
..headers['Content-Type'] = 'text/plain; charset=utf-8'
..body = text;
final response =
await client.send(request).timeout(const Duration(seconds: 10));
final client = _clientFactory();
final abort = Completer<void>();
final elapsed = Stopwatch()..start();
StreamIterator<List<int>>? body;
const timeout = CommunityGatewayException(
'Speech download timed out. Please retry.',
code: 'timeout');

void checkDeadline() {
if (elapsed.elapsed >= requestTimeout) throw timeout;
}

Future<Uint8List> receive() async {
final request =
http.AbortableRequest('POST', uri, abortTrigger: abort.future)
..followRedirects = false
..headers['Content-Type'] = 'text/plain; charset=utf-8'
..body = text;
final response = await client.send(request);
// A late header response must not start another body read after timeout.
if (abort.isCompleted) throw timeout;
checkDeadline();
if (response.statusCode != 200) {
throw const CommunityGatewayException(
'Speech is temporarily unavailable.',
code: 'unavailable');
}
if ((response.contentLength ?? 0) > 900000) {
throw const SpeechAudioException('speech-payload-too-large');
}
final stream = StreamIterator(response.stream);
body = stream;
final bytes = BytesBuilder(copy: false);
await for (final chunk
in response.stream.timeout(const Duration(seconds: 10))) {
while (await stream.moveNext()) {
checkDeadline();
final chunk = stream.current;
if (bytes.length + chunk.length > 900000) {
throw const SpeechAudioException('speech-payload-too-large');
}
bytes.add(chunk);
}
checkDeadline();
final result = bytes.takeBytes();
if (!isValidMp3(result)) {
throw const SpeechAudioException('speech-invalid-mp3');
}
return result;
}

try {
// One deadline includes headers and the entire body, even with progress.
return await receive()
.timeout(requestTimeout, onTimeout: () => throw timeout);
} finally {
elapsed.stop();
abort.complete();
// Cancellation and transport shutdown are real; Future.timeout alone
// only stops waiting. A slow cancellation must not delay the caller.
final cancellation = body?.cancel();
if (cancellation != null) {
unawaited(cancellation.catchError((Object _) {}));
}
client.close();
}
}
Expand Down
Loading