274 lines
8.5 KiB
Dart
274 lines
8.5 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:awesome_snackbar_content/awesome_snackbar_content.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:supabase_flutter/supabase_flutter.dart' show PostgrestException;
|
||
|
||
/// A global messenger key used to show snackbars from contexts without a
|
||
/// Scaffold (e.g. dialogs, background callbacks, or tests).
|
||
final GlobalKey<ScaffoldMessengerState> scaffoldMessengerKey =
|
||
GlobalKey<ScaffoldMessengerState>();
|
||
|
||
/// Helper wrappers around `awesome_snackbar_content` so that callers
|
||
/// can show snackbars with a consistent look and only specify a message and
|
||
/// semantic type.
|
||
|
||
/// Enumeration used by callers; the mapping to the package’s
|
||
/// [ContentType] is internal.
|
||
enum SnackType { success, info, warning, error }
|
||
|
||
ContentType _mapSnackType(SnackType t) {
|
||
switch (t) {
|
||
case SnackType.success:
|
||
return ContentType.success;
|
||
case SnackType.info:
|
||
return ContentType.help;
|
||
case SnackType.warning:
|
||
return ContentType.warning;
|
||
case SnackType.error:
|
||
return ContentType.failure;
|
||
}
|
||
}
|
||
|
||
/// Core function used by all of the convenience helpers below.
|
||
void showAwesomeSnackBar(
|
||
BuildContext context, {
|
||
required String title,
|
||
required String message,
|
||
required SnackType snackType,
|
||
bool retry = true,
|
||
}) {
|
||
// Add margin and padding so even very short messages feel substantial.
|
||
final snackBar = SnackBar(
|
||
elevation: 0,
|
||
behavior: SnackBarBehavior.floating,
|
||
// give floating snackbar some breathing room
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
padding: EdgeInsets.zero,
|
||
backgroundColor: Colors.transparent,
|
||
content: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||
child: AwesomeSnackbarContent(
|
||
title: title,
|
||
message: message,
|
||
contentType: _mapSnackType(snackType),
|
||
),
|
||
),
|
||
);
|
||
|
||
// Prefer a local ScaffoldMessenger when available, but prefer the global
|
||
// `scaffoldMessengerKey` if it's already ready. This increases reliability
|
||
// when callers are inside dialogs or other overlays.
|
||
final globalMessenger = scaffoldMessengerKey.currentState;
|
||
final localMessenger = ScaffoldMessenger.maybeOf(context);
|
||
final messenger = localMessenger ?? globalMessenger;
|
||
|
||
if (messenger == null) {
|
||
if (kDebugMode) {
|
||
debugPrint(
|
||
'showAwesomeSnackBar: no messenger available; scheduling fallback',
|
||
);
|
||
}
|
||
|
||
// If the scaffold messenger is not available yet, schedule a post-frame
|
||
// callback that uses the global messenger (if it becomes available).
|
||
if (retry) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
final gm = scaffoldMessengerKey.currentState;
|
||
if (gm != null) {
|
||
try {
|
||
gm
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(snackBar);
|
||
if (kDebugMode) {
|
||
debugPrint(
|
||
'showAwesomeSnackBar: shown via global messenger (post-frame)',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (kDebugMode) {
|
||
debugPrint('showAwesomeSnackBar: post-frame show failed: $e');
|
||
}
|
||
}
|
||
} else if (kDebugMode) {
|
||
debugPrint(
|
||
'showAwesomeSnackBar: global messenger still null after frame',
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
try {
|
||
messenger
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(snackBar);
|
||
if (kDebugMode) {
|
||
debugPrint(
|
||
'showAwesomeSnackBar: shown via ${identical(messenger, globalMessenger) ? 'global' : 'local'} messenger',
|
||
);
|
||
}
|
||
} catch (e) {
|
||
if (kDebugMode) debugPrint('showAwesomeSnackBar: show failed: $e');
|
||
}
|
||
}
|
||
|
||
void showSuccessSnackBar(BuildContext context, String message) {
|
||
showAwesomeSnackBar(
|
||
context,
|
||
title: 'Success',
|
||
message: message,
|
||
snackType: SnackType.success,
|
||
);
|
||
}
|
||
|
||
void showErrorSnackBar(BuildContext context, String message) {
|
||
showAwesomeSnackBar(
|
||
context,
|
||
title: 'Error',
|
||
message: message,
|
||
snackType: SnackType.error,
|
||
);
|
||
}
|
||
|
||
void showInfoSnackBar(BuildContext context, String message) {
|
||
showAwesomeSnackBar(
|
||
context,
|
||
title: 'Info',
|
||
message: message,
|
||
snackType: SnackType.info,
|
||
);
|
||
}
|
||
|
||
void showWarningSnackBar(BuildContext context, String message) {
|
||
showAwesomeSnackBar(
|
||
context,
|
||
title: 'Warning',
|
||
message: message,
|
||
snackType: SnackType.warning,
|
||
);
|
||
}
|
||
|
||
/// Global helpers that use the app-level `scaffoldMessengerKey` directly.
|
||
/// Use these from places where a valid `BuildContext` with a Scaffold may
|
||
/// not be available (e.g. dialog builders or background callbacks).
|
||
void showAwesomeSnackBarGlobal({
|
||
required String title,
|
||
required String message,
|
||
required SnackType snackType,
|
||
}) {
|
||
final gm = scaffoldMessengerKey.currentState;
|
||
if (gm == null) {
|
||
if (kDebugMode) {
|
||
debugPrint('showAwesomeSnackBarGlobal: global messenger null');
|
||
}
|
||
return;
|
||
}
|
||
|
||
final snackBar = SnackBar(
|
||
elevation: 0,
|
||
behavior: SnackBarBehavior.floating,
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
padding: EdgeInsets.zero,
|
||
backgroundColor: Colors.transparent,
|
||
content: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||
child: AwesomeSnackbarContent(
|
||
title: title,
|
||
message: message,
|
||
contentType: _mapSnackType(snackType),
|
||
),
|
||
),
|
||
);
|
||
|
||
try {
|
||
gm
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(snackBar);
|
||
if (kDebugMode) {
|
||
debugPrint('showAwesomeSnackBarGlobal: shown');
|
||
}
|
||
} catch (e) {
|
||
if (kDebugMode) {
|
||
debugPrint('showAwesomeSnackBarGlobal: show failed: $e');
|
||
}
|
||
}
|
||
}
|
||
|
||
void showSuccessSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
|
||
title: 'Success',
|
||
message: message,
|
||
snackType: SnackType.success,
|
||
);
|
||
|
||
void showErrorSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
|
||
title: 'Error',
|
||
message: message,
|
||
snackType: SnackType.error,
|
||
);
|
||
|
||
void showInfoSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
|
||
title: 'Info',
|
||
message: message,
|
||
snackType: SnackType.info,
|
||
);
|
||
|
||
void showWarningSnackBarGlobal(String message) => showAwesomeSnackBarGlobal(
|
||
title: 'Warning',
|
||
message: message,
|
||
snackType: SnackType.warning,
|
||
);
|
||
|
||
/// Returns true when [error] is a network-connectivity failure (socket closed,
|
||
/// no route to host, failed DNS lookup, etc.).
|
||
///
|
||
/// When this is true in a Brick-enabled app, the write was already queued in
|
||
/// the offline SQLite store and will sync automatically when the device
|
||
/// reconnects — so the UI should treat it as a success rather than an error.
|
||
bool isOfflineSaveError(Object error) {
|
||
if (error is PostgrestException) {
|
||
// Null code = no HTTP response received at all (pure network failure).
|
||
if (error.code == null) return true;
|
||
|
||
// Brick's RestOfflineQueueClient catches network errors and returns a
|
||
// synthetic StreamedResponse(501, body: 'unknown internal error') rather
|
||
// than rethrowing. PostgREST then parses this as
|
||
// PostgrestException(code: '501', message: 'unknown internal error').
|
||
// This is the primary offline indicator when Supabase is used with Brick.
|
||
if (error.code == '501' && error.message == 'unknown internal error') {
|
||
return true;
|
||
}
|
||
|
||
// Fallback: Supabase may wrap a SocketException in a PostgrestException
|
||
// message field before it reaches Brick's queue (rare, but defensive).
|
||
final pmsg = error.message.toLowerCase();
|
||
if (pmsg.contains('socketexception') ||
|
||
pmsg.contains('failed host lookup') ||
|
||
pmsg.contains('connection refused') ||
|
||
pmsg.contains('network is unreachable') ||
|
||
pmsg.contains('no route to host') ||
|
||
pmsg.contains('connection timed out') ||
|
||
pmsg.contains('failed to connect')) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Non-PostgrestException network errors (SocketException, ClientException)
|
||
// that propagate before Brick's HTTP interceptor can catch them.
|
||
final msg = error.toString().toLowerCase();
|
||
return msg.contains('socketexception') ||
|
||
msg.contains('socket') && msg.contains('exception') ||
|
||
msg.contains('connection refused') ||
|
||
msg.contains('network is unreachable') ||
|
||
msg.contains('no route to host') ||
|
||
msg.contains('failed host lookup') ||
|
||
msg.contains('connection reset') ||
|
||
msg.contains('broken pipe') ||
|
||
msg.contains('connection timed out') ||
|
||
msg.contains('clientexception') && msg.contains('network') ||
|
||
msg.contains('failed to connect') ||
|
||
msg.contains('errno = 111') || // ECONNREFUSED
|
||
msg.contains('errno = 101'); // ENETUNREACH
|
||
}
|