fix(#385): surface storage-init failure with a persistent warning
When the database can't open (e.g. the native sqlite library fails to load), every read/write silently failed and the app opened to an empty, normal-looking screen — the user thinks their history was wiped (SAFELANE §6 violation). Probe the storage layer in main() before any store reads (BlobStore.verifyReadWrite). On failure, a StorageHealthService one-way latch records it, and a persistent, non-dismissable banner is shown above the whole app: history is not lost, storage is unavailable, messages are NOT being saved, restart after fixing. Cross-platform (probe goes through drift, covers web too). Tests: service state/latch + banner show/hide widget test. Gemini-reviewed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/385-storage-health
parent
b6a801166a
commit
4f9db98e53
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Tracks whether the app's storage layer (drift/SQLite) opened successfully at
|
||||||
|
/// startup (#385).
|
||||||
|
///
|
||||||
|
/// When the database can't open — e.g. the native `sqlite3` library fails to
|
||||||
|
/// load — every read and write silently fails and the app looks wiped. This
|
||||||
|
/// holds that state so the UI can show a loud, persistent warning instead of an
|
||||||
|
/// empty, normal-looking screen (SAFELANE §6: no silent failures).
|
||||||
|
class StorageHealthService extends ChangeNotifier {
|
||||||
|
bool _available = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
/// True until a startup probe proves storage cannot be read/written.
|
||||||
|
bool get available => _available;
|
||||||
|
|
||||||
|
/// The underlying error, for the log and diagnostics. Null when healthy.
|
||||||
|
String? get error => _error;
|
||||||
|
|
||||||
|
/// Marks storage as unavailable and records [error]. A one-way latch: once
|
||||||
|
/// unavailable it stays that way for the session (recovery is a restart), so
|
||||||
|
/// a later call is a no-op and does not re-notify.
|
||||||
|
void markUnavailable(Object error) {
|
||||||
|
if (!_available) return;
|
||||||
|
_available = false;
|
||||||
|
_error = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/l10n.dart';
|
||||||
|
|
||||||
|
/// A persistent, non-dismissable banner shown above the whole app when the
|
||||||
|
/// storage layer failed to open (#385).
|
||||||
|
///
|
||||||
|
/// SAFELANE §6: a storage failure must be loud and stay visible — not a 4s
|
||||||
|
/// toast, and never a silent empty screen. When [show] is false this is a
|
||||||
|
/// transparent pass-through and adds no layout.
|
||||||
|
class StorageUnavailableBanner extends StatelessWidget {
|
||||||
|
final bool show;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const StorageUnavailableBanner({
|
||||||
|
super.key,
|
||||||
|
required this.show,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!show) return child;
|
||||||
|
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
|
color: scheme.errorContainer,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: scheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.l10n.storageUnavailableTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: scheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
context.l10n.storageUnavailableBody,
|
||||||
|
style: TextStyle(color: scheme.onErrorContainer),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/services/storage_health_service.dart';
|
||||||
|
|
||||||
|
/// #385: the service must default to healthy and flip loudly (notifying) the
|
||||||
|
/// moment storage is proven unusable.
|
||||||
|
void main() {
|
||||||
|
test('defaults to available with no error', () {
|
||||||
|
final s = StorageHealthService();
|
||||||
|
expect(s.available, isTrue);
|
||||||
|
expect(s.error, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markUnavailable flips state, records the error, and notifies', () {
|
||||||
|
final s = StorageHealthService();
|
||||||
|
var notifications = 0;
|
||||||
|
s.addListener(() => notifications++);
|
||||||
|
|
||||||
|
s.markUnavailable(Exception('sqlite3.dll not found'));
|
||||||
|
|
||||||
|
expect(s.available, isFalse);
|
||||||
|
expect(s.error, contains('sqlite3.dll not found'));
|
||||||
|
expect(notifications, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one-way latch: a later mark is a no-op and keeps the first error', () {
|
||||||
|
final s = StorageHealthService();
|
||||||
|
var notifications = 0;
|
||||||
|
s.addListener(() => notifications++);
|
||||||
|
|
||||||
|
s.markUnavailable('first');
|
||||||
|
s.markUnavailable('second');
|
||||||
|
|
||||||
|
expect(notifications, 1, reason: 'stays unavailable, does not re-notify');
|
||||||
|
expect(s.available, isFalse);
|
||||||
|
expect(s.error, contains('first'), reason: 'first error is retained');
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_open/l10n/app_localizations.dart';
|
||||||
|
import 'package:meshcore_open/widgets/storage_unavailable_banner.dart';
|
||||||
|
|
||||||
|
/// #385: the banner must be invisible when healthy and a loud, persistent
|
||||||
|
/// warning above the app when storage is down.
|
||||||
|
Widget _wrap(Widget child) => MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('hidden when show=false: only the child renders', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(const StorageUnavailableBanner(show: false, child: Text('APP'))),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('APP'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.warning_amber_rounded), findsNothing);
|
||||||
|
expect(find.textContaining('NOT being saved'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shown when show=true: warning + restart hint above the child', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_wrap(const StorageUnavailableBanner(show: true, child: Text('APP'))),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget);
|
||||||
|
expect(find.textContaining('NOT being saved'), findsOneWidget);
|
||||||
|
expect(find.textContaining('Restart the app'), findsOneWidget);
|
||||||
|
// The app content is still present below the banner.
|
||||||
|
expect(find.text('APP'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in new issue