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>fix/389-back-nav
parent
b6a801166a
commit
d68c6dfdd8
@ -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