chore: initial release
This commit is contained in:
278
lib/core/services/animation_service.dart
Normal file
278
lib/core/services/animation_service.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Service for managing animations with performance optimization and accessibility
|
||||
class AnimationService {
|
||||
/// Get optimized animation duration based on context and settings
|
||||
static Duration getOptimizedDuration(
|
||||
BuildContext context,
|
||||
Duration defaultDuration, {
|
||||
bool respectReducedMotion = true,
|
||||
}) {
|
||||
if (respectReducedMotion && MediaQuery.of(context).disableAnimations) {
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
// Optimize for 60fps - keep animations under 300ms for snappy feel
|
||||
final optimizedDuration = Duration(
|
||||
milliseconds: (defaultDuration.inMilliseconds * 0.8).round().clamp(
|
||||
100,
|
||||
300,
|
||||
),
|
||||
);
|
||||
|
||||
return optimizedDuration;
|
||||
}
|
||||
|
||||
/// Get optimized curve for smooth 60fps animations
|
||||
static Curve getOptimizedCurve({Curve defaultCurve = Curves.easeInOut}) {
|
||||
// Use curves that are optimized for mobile performance
|
||||
final curveType = defaultCurve.runtimeType.toString();
|
||||
|
||||
// Replace performance-heavy curves with lighter alternatives
|
||||
if (curveType.contains('Bounce')) {
|
||||
return Curves.easeInOutQuart; // Replace heavy bounce with smooth curve
|
||||
} else if (curveType.contains('Elastic')) {
|
||||
return Curves.easeInOutBack; // Lighter alternative to elastic
|
||||
} else if (defaultCurve == Curves.easeInOut) {
|
||||
return Curves.easeInOutCubic; // Better performance than default
|
||||
}
|
||||
|
||||
return defaultCurve;
|
||||
}
|
||||
|
||||
/// Create performant fade transition
|
||||
static Widget createOptimizedFadeTransition({
|
||||
required Widget child,
|
||||
required Animation<double> animation,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
}
|
||||
|
||||
/// Create performant slide transition
|
||||
static Widget createOptimizedSlideTransition({
|
||||
required Widget child,
|
||||
required Animation<Offset> animation,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return SlideTransition(position: animation, child: child);
|
||||
}
|
||||
|
||||
/// Create performant scale transition
|
||||
static Widget createOptimizedScaleTransition({
|
||||
required Widget child,
|
||||
required Animation<double> animation,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return ScaleTransition(scale: animation, child: child);
|
||||
}
|
||||
|
||||
/// Create optimized page transition
|
||||
static PageRouteBuilder createOptimizedPageRoute({
|
||||
required Widget page,
|
||||
Duration? transitionDuration,
|
||||
PageTransitionType type = PageTransitionType.slide,
|
||||
}) {
|
||||
return PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration:
|
||||
transitionDuration ?? const Duration(milliseconds: 250),
|
||||
reverseTransitionDuration:
|
||||
transitionDuration ?? const Duration(milliseconds: 200),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final optimizedCurve = getOptimizedCurve();
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: optimizedCurve,
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case PageTransitionType.fade:
|
||||
return FadeTransition(opacity: curvedAnimation, child: child);
|
||||
case PageTransitionType.slide:
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1.0, 0.0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
);
|
||||
case PageTransitionType.scale:
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(
|
||||
begin: 0.8,
|
||||
end: 1.0,
|
||||
).animate(curvedAnimation),
|
||||
child: FadeTransition(opacity: curvedAnimation, child: child),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Create staggered animation for lists
|
||||
static Widget createStaggeredListAnimation({
|
||||
required Widget child,
|
||||
required int index,
|
||||
Duration? delay,
|
||||
Duration? duration,
|
||||
}) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: duration ?? const Duration(milliseconds: 200),
|
||||
curve: getOptimizedCurve(),
|
||||
builder: (context, value, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - value)),
|
||||
child: Opacity(opacity: value, child: child),
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create performant shimmer animation
|
||||
static Widget createOptimizedShimmer({
|
||||
required Widget child,
|
||||
Duration? duration,
|
||||
Color? baseColor,
|
||||
Color? highlightColor,
|
||||
}) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: duration ?? const Duration(milliseconds: 1500),
|
||||
curve: Curves.linear,
|
||||
builder: (context, value, child) {
|
||||
return ShaderMask(
|
||||
shaderCallback: (bounds) {
|
||||
return LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
baseColor ?? context.conduitTheme.shimmerBase,
|
||||
highlightColor ?? context.conduitTheme.shimmerHighlight,
|
||||
baseColor ?? context.conduitTheme.shimmerBase,
|
||||
],
|
||||
stops: [0.0, value, 1.0],
|
||||
).createShader(bounds);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create optimized rotation animation
|
||||
static Widget createOptimizedRotation({
|
||||
required Widget child,
|
||||
required Animation<double> animation,
|
||||
double turns = 1.0,
|
||||
}) {
|
||||
return RotationTransition(
|
||||
turns: Tween<double>(begin: 0, end: turns).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if device can handle complex animations
|
||||
static bool canHandleComplexAnimations(BuildContext context) {
|
||||
// Simple heuristic based on screen density and platform
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final totalPixels = screenSize.width * screenSize.height * devicePixelRatio;
|
||||
|
||||
// If total pixels exceed 4M, assume it's a high-end device
|
||||
return totalPixels > 4000000;
|
||||
}
|
||||
|
||||
/// Create adaptive animation based on device capability
|
||||
static Widget createAdaptiveAnimation({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
required Widget Function(Widget) complexAnimation,
|
||||
required Widget Function(Widget) simpleAnimation,
|
||||
}) {
|
||||
if (canHandleComplexAnimations(context) &&
|
||||
!MediaQuery.of(context).disableAnimations) {
|
||||
return complexAnimation(child);
|
||||
} else {
|
||||
return simpleAnimation(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum for page transition types
|
||||
enum PageTransitionType { fade, slide, scale }
|
||||
|
||||
/// Provider for reduced motion preference
|
||||
final reducedMotionProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
/// Provider for animation performance settings
|
||||
final animationPerformanceProvider = StateProvider<AnimationPerformance>((ref) {
|
||||
return AnimationPerformance.adaptive;
|
||||
});
|
||||
|
||||
/// Animation performance levels
|
||||
enum AnimationPerformance {
|
||||
high, // All animations enabled
|
||||
adaptive, // Adaptive based on device
|
||||
reduced, // Simplified animations
|
||||
minimal, // Essential animations only
|
||||
}
|
||||
|
||||
/// Provider for managing animation settings
|
||||
final animationSettingsProvider =
|
||||
StateNotifierProvider<AnimationSettingsNotifier, AnimationSettings>(
|
||||
(ref) => AnimationSettingsNotifier(),
|
||||
);
|
||||
|
||||
class AnimationSettings {
|
||||
final bool reduceMotion;
|
||||
final AnimationPerformance performance;
|
||||
final double animationSpeed;
|
||||
|
||||
const AnimationSettings({
|
||||
this.reduceMotion = false,
|
||||
this.performance = AnimationPerformance.adaptive,
|
||||
this.animationSpeed = 1.0,
|
||||
});
|
||||
|
||||
AnimationSettings copyWith({
|
||||
bool? reduceMotion,
|
||||
AnimationPerformance? performance,
|
||||
double? animationSpeed,
|
||||
}) {
|
||||
return AnimationSettings(
|
||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||
performance: performance ?? this.performance,
|
||||
animationSpeed: animationSpeed ?? this.animationSpeed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AnimationSettingsNotifier extends StateNotifier<AnimationSettings> {
|
||||
AnimationSettingsNotifier() : super(const AnimationSettings());
|
||||
|
||||
void setReduceMotion(bool reduce) {
|
||||
state = state.copyWith(reduceMotion: reduce);
|
||||
}
|
||||
|
||||
void setPerformance(AnimationPerformance performance) {
|
||||
state = state.copyWith(performance: performance);
|
||||
}
|
||||
|
||||
void setAnimationSpeed(double speed) {
|
||||
state = state.copyWith(animationSpeed: speed.clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
Duration adjustDuration(Duration baseDuration) {
|
||||
if (state.reduceMotion) return Duration.zero;
|
||||
|
||||
final adjustedMs = (baseDuration.inMilliseconds / state.animationSpeed)
|
||||
.round();
|
||||
return Duration(milliseconds: adjustedMs);
|
||||
}
|
||||
}
|
||||
3522
lib/core/services/api_service.dart
Normal file
3522
lib/core/services/api_service.dart
Normal file
File diff suppressed because it is too large
Load Diff
347
lib/core/services/attachment_upload_queue.dart
Normal file
347
lib/core/services/attachment_upload_queue.dart
Normal file
@@ -0,0 +1,347 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Status of a queued attachment upload
|
||||
enum QueuedAttachmentStatus { pending, uploading, completed, failed, cancelled }
|
||||
|
||||
/// Metadata for a queued attachment
|
||||
class QueuedAttachment {
|
||||
final String id; // local queue id
|
||||
final String filePath;
|
||||
final String fileName;
|
||||
final int fileSize;
|
||||
final String? mimeType;
|
||||
final String? checksum;
|
||||
final DateTime enqueuedAt;
|
||||
|
||||
// Upload state
|
||||
int retryCount;
|
||||
DateTime? nextRetryAt;
|
||||
QueuedAttachmentStatus status;
|
||||
String? lastError;
|
||||
String? fileId; // server-side file id once uploaded
|
||||
|
||||
QueuedAttachment({
|
||||
required this.id,
|
||||
required this.filePath,
|
||||
required this.fileName,
|
||||
required this.fileSize,
|
||||
this.mimeType,
|
||||
this.checksum,
|
||||
DateTime? enqueuedAt,
|
||||
this.retryCount = 0,
|
||||
this.nextRetryAt,
|
||||
this.status = QueuedAttachmentStatus.pending,
|
||||
this.lastError,
|
||||
this.fileId,
|
||||
}) : enqueuedAt = enqueuedAt ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'filePath': filePath,
|
||||
'fileName': fileName,
|
||||
'fileSize': fileSize,
|
||||
'mimeType': mimeType,
|
||||
'checksum': checksum,
|
||||
'enqueuedAt': enqueuedAt.toIso8601String(),
|
||||
'retryCount': retryCount,
|
||||
'nextRetryAt': nextRetryAt?.toIso8601String(),
|
||||
'status': status.name,
|
||||
'lastError': lastError,
|
||||
'fileId': fileId,
|
||||
};
|
||||
|
||||
factory QueuedAttachment.fromJson(Map<String, dynamic> json) =>
|
||||
QueuedAttachment(
|
||||
id: json['id'] as String,
|
||||
filePath: json['filePath'] as String,
|
||||
fileName: json['fileName'] as String,
|
||||
fileSize: (json['fileSize'] as num).toInt(),
|
||||
mimeType: json['mimeType'] as String?,
|
||||
checksum: json['checksum'] as String?,
|
||||
enqueuedAt:
|
||||
DateTime.tryParse(json['enqueuedAt'] ?? '') ?? DateTime.now(),
|
||||
retryCount: (json['retryCount'] as num?)?.toInt() ?? 0,
|
||||
nextRetryAt: json['nextRetryAt'] != null
|
||||
? DateTime.tryParse(json['nextRetryAt'])
|
||||
: null,
|
||||
status: QueuedAttachmentStatus.values.firstWhere(
|
||||
(e) => e.name == json['status'],
|
||||
orElse: () => QueuedAttachmentStatus.pending,
|
||||
),
|
||||
lastError: json['lastError'] as String?,
|
||||
fileId: json['fileId'] as String?,
|
||||
);
|
||||
|
||||
QueuedAttachment copyWith({
|
||||
int? retryCount,
|
||||
DateTime? nextRetryAt,
|
||||
QueuedAttachmentStatus? status,
|
||||
String? lastError,
|
||||
String? fileId,
|
||||
}) => QueuedAttachment(
|
||||
id: id,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
mimeType: mimeType,
|
||||
checksum: checksum,
|
||||
enqueuedAt: enqueuedAt,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
nextRetryAt: nextRetryAt ?? this.nextRetryAt,
|
||||
status: status ?? this.status,
|
||||
lastError: lastError ?? this.lastError,
|
||||
fileId: fileId ?? this.fileId,
|
||||
);
|
||||
}
|
||||
|
||||
typedef UploadCallback =
|
||||
Future<String> Function(String filePath, String fileName);
|
||||
typedef AttachmentsEventCallback = void Function(List<QueuedAttachment> queue);
|
||||
|
||||
/// A lightweight background queue to upload attachments when back online.
|
||||
class AttachmentUploadQueue {
|
||||
static final AttachmentUploadQueue _instance =
|
||||
AttachmentUploadQueue._internal();
|
||||
factory AttachmentUploadQueue() => _instance;
|
||||
AttachmentUploadQueue._internal();
|
||||
|
||||
static const String _prefsKey = 'attachment_upload_queue';
|
||||
static const int _maxRetries = 4;
|
||||
static const Duration _baseRetryDelay = Duration(seconds: 5);
|
||||
static const Duration _maxRetryDelay = Duration(minutes: 5);
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final List<QueuedAttachment> _queue = [];
|
||||
Timer? _retryTimer;
|
||||
bool _isProcessing = false;
|
||||
|
||||
// Dependencies
|
||||
UploadCallback? _onUpload;
|
||||
AttachmentsEventCallback? _onQueueChanged;
|
||||
|
||||
// Streams
|
||||
final _queueController = StreamController<List<QueuedAttachment>>.broadcast();
|
||||
Stream<List<QueuedAttachment>> get queueStream => _queueController.stream;
|
||||
|
||||
List<QueuedAttachment> get queue => List.unmodifiable(_queue);
|
||||
|
||||
Future<void> initialize({
|
||||
required UploadCallback onUpload,
|
||||
AttachmentsEventCallback? onQueueChanged,
|
||||
}) async {
|
||||
_onUpload = onUpload;
|
||||
_onQueueChanged = onQueueChanged;
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
await _load();
|
||||
_startPeriodicProcessing();
|
||||
debugPrint(
|
||||
'DEBUG: AttachmentUploadQueue initialized with ${_queue.length} items',
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> enqueue({
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
required int fileSize,
|
||||
String? mimeType,
|
||||
String? checksum,
|
||||
}) async {
|
||||
final id = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
final item = QueuedAttachment(
|
||||
id: id,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
mimeType: mimeType,
|
||||
checksum: checksum,
|
||||
status: QueuedAttachmentStatus.pending,
|
||||
);
|
||||
_queue.add(item);
|
||||
await _save();
|
||||
_notify();
|
||||
_processSafe();
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<void> processQueue() async {
|
||||
if (_isProcessing) return;
|
||||
if (_onUpload == null) return;
|
||||
|
||||
_isProcessing = true;
|
||||
try {
|
||||
// Quick network probe using Dio HEAD to common health path if possible
|
||||
final dio = Dio();
|
||||
try {
|
||||
await dio.head('/api/health').timeout(const Duration(seconds: 3));
|
||||
} catch (_) {
|
||||
// Best effort; continue and let upload fail if actually offline
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final pending = _queue.where(
|
||||
(e) =>
|
||||
(e.status == QueuedAttachmentStatus.pending ||
|
||||
e.status == QueuedAttachmentStatus.failed) &&
|
||||
(e.nextRetryAt == null || now.isAfter(e.nextRetryAt!)),
|
||||
);
|
||||
|
||||
for (final item in List<QueuedAttachment>.from(pending)) {
|
||||
await _processSingle(item);
|
||||
}
|
||||
} finally {
|
||||
_isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processSingle(QueuedAttachment item) async {
|
||||
if (_onUpload == null) return;
|
||||
try {
|
||||
_update(item.id, item.copyWith(status: QueuedAttachmentStatus.uploading));
|
||||
|
||||
final fileId = await _onUpload!.call(item.filePath, item.fileName);
|
||||
|
||||
_update(
|
||||
item.id,
|
||||
item.copyWith(
|
||||
status: QueuedAttachmentStatus.completed,
|
||||
fileId: fileId,
|
||||
retryCount: 0,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
),
|
||||
);
|
||||
|
||||
await _save();
|
||||
_notify();
|
||||
debugPrint(
|
||||
'DEBUG: Attachment ${item.id} uploaded successfully (fileId=$fileId)',
|
||||
);
|
||||
} catch (e) {
|
||||
final retries = item.retryCount + 1;
|
||||
if (retries >= _maxRetries) {
|
||||
_update(
|
||||
item.id,
|
||||
item.copyWith(
|
||||
status: QueuedAttachmentStatus.failed,
|
||||
retryCount: retries,
|
||||
lastError: e.toString(),
|
||||
),
|
||||
);
|
||||
await _save();
|
||||
_notify();
|
||||
debugPrint(
|
||||
'WARNING: Attachment ${item.id} failed after $_maxRetries attempts',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final delay = _retryDelayWithJitter(retries);
|
||||
_update(
|
||||
item.id,
|
||||
item.copyWith(
|
||||
status: QueuedAttachmentStatus.pending,
|
||||
retryCount: retries,
|
||||
nextRetryAt: DateTime.now().add(delay),
|
||||
lastError: e.toString(),
|
||||
),
|
||||
);
|
||||
await _save();
|
||||
_notify();
|
||||
debugPrint(
|
||||
'DEBUG: Scheduled retry for attachment ${item.id} in ${delay.inSeconds}s',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Duration _retryDelayWithJitter(int retryCount) {
|
||||
final base = _baseRetryDelay.inMilliseconds;
|
||||
final exp = min(
|
||||
base * pow(2, retryCount - 1),
|
||||
_maxRetryDelay.inMilliseconds.toDouble(),
|
||||
).toInt();
|
||||
final jitter = Random().nextInt(1000); // up to 1s jitter
|
||||
return Duration(milliseconds: exp + jitter);
|
||||
}
|
||||
|
||||
void _startPeriodicProcessing() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = Timer.periodic(
|
||||
const Duration(seconds: 10),
|
||||
(_) => _processSafe(),
|
||||
);
|
||||
// Also kick once after a short delay
|
||||
Timer(const Duration(milliseconds: 500), _processSafe);
|
||||
}
|
||||
|
||||
void _processSafe() {
|
||||
// Fire and forget
|
||||
unawaited(processQueue());
|
||||
}
|
||||
|
||||
void _update(String id, QueuedAttachment updated) {
|
||||
final idx = _queue.indexWhere((e) => e.id == id);
|
||||
if (idx != -1) {
|
||||
_queue[idx] = updated;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> remove(String id) async {
|
||||
_queue.removeWhere((e) => e.id == id);
|
||||
await _save();
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> retry(String id) async {
|
||||
final idx = _queue.indexWhere((e) => e.id == id);
|
||||
if (idx == -1) return;
|
||||
_queue[idx] = _queue[idx].copyWith(
|
||||
status: QueuedAttachmentStatus.pending,
|
||||
retryCount: 0,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
);
|
||||
await _save();
|
||||
_notify();
|
||||
_processSafe();
|
||||
}
|
||||
|
||||
Future<void> clearFailed() async {
|
||||
_queue.removeWhere((e) => e.status == QueuedAttachmentStatus.failed);
|
||||
await _save();
|
||||
_notify();
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
_queue.clear();
|
||||
await _save();
|
||||
_notify();
|
||||
}
|
||||
|
||||
// Utilities
|
||||
Future<void> _load() async {
|
||||
final jsonStr = (_prefs ?? await SharedPreferences.getInstance()).getString(
|
||||
_prefsKey,
|
||||
);
|
||||
if (jsonStr == null || jsonStr.isEmpty) return;
|
||||
final list = (jsonDecode(jsonStr) as List).cast<Map<String, dynamic>>();
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map(QueuedAttachment.fromJson));
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final prefs = _prefs ?? await SharedPreferences.getInstance();
|
||||
final list = _queue.map((e) => e.toJson()).toList(growable: false);
|
||||
await prefs.setString(_prefsKey, jsonEncode(list));
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
_onQueueChanged?.call(queue);
|
||||
_queueController.add(queue);
|
||||
}
|
||||
}
|
||||
118
lib/core/services/connectivity_service.dart
Normal file
118
lib/core/services/connectivity_service.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../providers/app_providers.dart';
|
||||
|
||||
enum ConnectivityStatus { online, offline, checking }
|
||||
|
||||
class ConnectivityService {
|
||||
final Dio _dio;
|
||||
Timer? _connectivityTimer;
|
||||
final _connectivityController =
|
||||
StreamController<ConnectivityStatus>.broadcast();
|
||||
ConnectivityStatus _lastStatus = ConnectivityStatus.checking;
|
||||
|
||||
ConnectivityService(this._dio) {
|
||||
_startConnectivityMonitoring();
|
||||
}
|
||||
|
||||
Stream<ConnectivityStatus> get connectivityStream =>
|
||||
_connectivityController.stream;
|
||||
ConnectivityStatus get currentStatus => _lastStatus;
|
||||
|
||||
void _startConnectivityMonitoring() {
|
||||
// Initial check after a brief delay to avoid showing offline during startup
|
||||
Timer(const Duration(milliseconds: 1000), () {
|
||||
_checkConnectivity();
|
||||
});
|
||||
|
||||
// Check every 5 seconds
|
||||
_connectivityTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
_checkConnectivity();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkConnectivity() async {
|
||||
try {
|
||||
// DNS lookup is a lightweight, permission-free reachability check
|
||||
final result = await InternetAddress.lookup(
|
||||
'google.com',
|
||||
).timeout(const Duration(seconds: 3));
|
||||
|
||||
if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) {
|
||||
_updateStatus(ConnectivityStatus.online);
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// Swallow and continue to HTTP reachability check
|
||||
}
|
||||
|
||||
// As a secondary check, hit a public 204 endpoint that returns quickly
|
||||
try {
|
||||
await _dio
|
||||
.get(
|
||||
'https://www.google.com/generate_204',
|
||||
options: Options(
|
||||
method: 'GET',
|
||||
sendTimeout: const Duration(seconds: 3),
|
||||
receiveTimeout: const Duration(seconds: 3),
|
||||
followRedirects: false,
|
||||
validateStatus: (status) => status != null && status < 400,
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
_updateStatus(ConnectivityStatus.online);
|
||||
} catch (_) {
|
||||
_updateStatus(ConnectivityStatus.offline);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStatus(ConnectivityStatus status) {
|
||||
if (_lastStatus != status) {
|
||||
_lastStatus = status;
|
||||
_connectivityController.add(status);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> checkConnectivity() async {
|
||||
await _checkConnectivity();
|
||||
return _lastStatus == ConnectivityStatus.online;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_connectivityTimer?.cancel();
|
||||
_connectivityController.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Providers
|
||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||
final dio = ref.watch(dioProvider);
|
||||
final service = ConnectivityService(dio);
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
});
|
||||
|
||||
final connectivityStatusProvider = StreamProvider<ConnectivityStatus>((ref) {
|
||||
final service = ref.watch(connectivityServiceProvider);
|
||||
return service.connectivityStream;
|
||||
});
|
||||
|
||||
final isOnlineProvider = Provider<bool>((ref) {
|
||||
// In reviewer mode, treat app as online to enable flows
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) return true;
|
||||
final status = ref.watch(connectivityStatusProvider);
|
||||
return status.when(
|
||||
data: (status) => status == ConnectivityStatus.online,
|
||||
loading: () => true, // Assume online while checking
|
||||
error: (_, _) =>
|
||||
true, // Assume online on error to avoid false offline states
|
||||
);
|
||||
});
|
||||
|
||||
// Dio provider (if not already defined elsewhere)
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
return Dio(); // This should be configured with your base URL
|
||||
});
|
||||
67
lib/core/services/deep_link_service.dart
Normal file
67
lib/core/services/deep_link_service.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
import '../../features/files/views/files_page.dart';
|
||||
import '../../features/profile/views/profile_page.dart';
|
||||
|
||||
/// Service for handling deep links and navigation routing
|
||||
class DeepLinkService {
|
||||
/// Route to chat tab
|
||||
static void navigateToChat(BuildContext context) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChatPage()),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// In single-screen mode, files/profile deep links route via navigator
|
||||
static void navigateToFiles(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const FilesPage()),
|
||||
);
|
||||
}
|
||||
|
||||
static void navigateToProfile(BuildContext context) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ProfilePage()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse route and determine target tab
|
||||
static String? parsePath(String route) {
|
||||
switch (route) {
|
||||
case '/chat':
|
||||
case '/main/chat':
|
||||
return '/chat';
|
||||
case '/files':
|
||||
case '/main/files':
|
||||
return '/files';
|
||||
case '/profile':
|
||||
case '/main/profile':
|
||||
return '/profile';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle deep link navigation
|
||||
static Widget handleDeepLink(String route) {
|
||||
final path = parsePath(route);
|
||||
switch (path) {
|
||||
case '/files':
|
||||
return const FilesPage();
|
||||
case '/profile':
|
||||
return const ProfilePage();
|
||||
case '/chat':
|
||||
default:
|
||||
return const ChatPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for deep link navigation
|
||||
final deepLinkProvider = Provider<DeepLinkService>((ref) => DeepLinkService());
|
||||
396
lib/core/services/enhanced_accessibility_service.dart
Normal file
396
lib/core/services/enhanced_accessibility_service.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced accessibility service for WCAG 2.2 AA compliance
|
||||
class EnhancedAccessibilityService {
|
||||
/// Announce text to screen readers
|
||||
static void announce(
|
||||
String message, {
|
||||
TextDirection textDirection = TextDirection.ltr,
|
||||
}) {
|
||||
SemanticsService.announce(message, textDirection);
|
||||
}
|
||||
|
||||
/// Announce loading state
|
||||
static void announceLoading(String loadingMessage) {
|
||||
announce('Loading: $loadingMessage');
|
||||
}
|
||||
|
||||
/// Announce error with helpful context
|
||||
static void announceError(String error, {String? suggestion}) {
|
||||
final message = suggestion != null
|
||||
? 'Error: $error. $suggestion'
|
||||
: 'Error: $error';
|
||||
announce(message);
|
||||
}
|
||||
|
||||
/// Announce success with context
|
||||
static void announceSuccess(String successMessage) {
|
||||
announce('Success: $successMessage');
|
||||
}
|
||||
|
||||
/// Check if reduce motion is enabled
|
||||
static bool shouldReduceMotion(BuildContext context) {
|
||||
return MediaQuery.of(context).disableAnimations;
|
||||
}
|
||||
|
||||
/// Get appropriate animation duration based on motion settings
|
||||
static Duration getAnimationDuration(
|
||||
BuildContext context,
|
||||
Duration defaultDuration,
|
||||
) {
|
||||
return shouldReduceMotion(context) ? Duration.zero : defaultDuration;
|
||||
}
|
||||
|
||||
/// Get text scale factor with bounds for accessibility
|
||||
static double getBoundedTextScaleFactor(BuildContext context) {
|
||||
final textScaler = MediaQuery.of(context).textScaler;
|
||||
final textScaleFactor = textScaler.scale(1.0);
|
||||
// Ensure text doesn't get too small or too large
|
||||
return textScaleFactor.clamp(0.8, 3.0);
|
||||
}
|
||||
|
||||
/// Create accessible button with proper semantics
|
||||
static Widget createAccessibleButton({
|
||||
required Widget child,
|
||||
required VoidCallback? onPressed,
|
||||
required String semanticLabel,
|
||||
String? semanticHint,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) => Semantics(
|
||||
label: semanticLabel,
|
||||
hint: semanticHint,
|
||||
button: true,
|
||||
enabled: onPressed != null,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(44, 44), // WCAG minimum touch target
|
||||
backgroundColor: isDestructive ? context.conduitTheme.error : null,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible icon button with proper semantics
|
||||
static Widget createAccessibleIconButton({
|
||||
required IconData icon,
|
||||
required VoidCallback? onPressed,
|
||||
required String semanticLabel,
|
||||
String? semanticHint,
|
||||
Color? iconColor,
|
||||
double iconSize = 24,
|
||||
}) {
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
hint: semanticHint,
|
||||
button: true,
|
||||
enabled: onPressed != null,
|
||||
child: SizedBox(
|
||||
width: 44, // Minimum touch target
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: iconSize, color: iconColor),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible text field with proper labels
|
||||
static Widget createAccessibleTextField({
|
||||
required String label,
|
||||
TextEditingController? controller,
|
||||
String? hintText,
|
||||
String? errorText,
|
||||
bool isRequired = false,
|
||||
TextInputType? keyboardType,
|
||||
bool obscureText = false,
|
||||
ValueChanged<String>? onChanged,
|
||||
}) {
|
||||
final effectiveLabel = isRequired ? '$label *' : label;
|
||||
|
||||
return Semantics(
|
||||
label: effectiveLabel,
|
||||
hint: hintText,
|
||||
textField: true,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
obscureText: obscureText,
|
||||
onChanged: onChanged,
|
||||
decoration: InputDecoration(
|
||||
labelText: effectiveLabel,
|
||||
hintText: hintText,
|
||||
errorText: errorText,
|
||||
helperText: isRequired ? '* Required field' : null,
|
||||
prefixIcon: errorText != null
|
||||
? Builder(
|
||||
builder: (context) => Icon(
|
||||
Icons.error_outline,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible card with proper semantics
|
||||
static Widget createAccessibleCard({
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
String? semanticLabel,
|
||||
String? semanticHint,
|
||||
bool isSelected = false,
|
||||
}) {
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
hint: semanticHint,
|
||||
button: onTap != null,
|
||||
selected: isSelected,
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible loading indicator
|
||||
static Widget createAccessibleLoadingIndicator({
|
||||
String? loadingMessage,
|
||||
double size = 24,
|
||||
}) {
|
||||
return Semantics(
|
||||
label: loadingMessage ?? 'Loading',
|
||||
liveRegion: true,
|
||||
child: SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: const CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible image with alt text
|
||||
static Widget createAccessibleImage({
|
||||
required ImageProvider image,
|
||||
required String altText,
|
||||
bool isDecorative = false,
|
||||
double? width,
|
||||
double? height,
|
||||
BoxFit fit = BoxFit.cover,
|
||||
}) {
|
||||
if (isDecorative) {
|
||||
return Semantics(
|
||||
excludeSemantics: true,
|
||||
child: Image(image: image, width: width, height: height, fit: fit),
|
||||
);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
label: altText,
|
||||
image: true,
|
||||
child: Image(image: image, width: width, height: height, fit: fit),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible toggle switch
|
||||
static Widget createAccessibleSwitch({
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
required String label,
|
||||
String? description,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) => Semantics(
|
||||
label: label,
|
||||
value: value ? 'On' : 'Off',
|
||||
hint: description,
|
||||
toggled: value,
|
||||
onTap: onChanged != null ? () => onChanged(!value) : null,
|
||||
child: SwitchListTile(
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
subtitle: description != null
|
||||
? Text(
|
||||
description,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
)
|
||||
: null,
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible slider
|
||||
static Widget createAccessibleSlider({
|
||||
required double value,
|
||||
required ValueChanged<double>? onChanged,
|
||||
required String label,
|
||||
double min = 0.0,
|
||||
double max = 1.0,
|
||||
int? divisions,
|
||||
String Function(double)? valueFormatter,
|
||||
}) {
|
||||
final formattedValue =
|
||||
valueFormatter?.call(value) ?? value.toStringAsFixed(1);
|
||||
|
||||
return Semantics(
|
||||
label: label,
|
||||
value: formattedValue,
|
||||
increasedValue:
|
||||
valueFormatter?.call((value + 0.1).clamp(min, max)) ??
|
||||
(value + 0.1).clamp(min, max).toStringAsFixed(1),
|
||||
decreasedValue:
|
||||
valueFormatter?.call((value - 0.1).clamp(min, max)) ??
|
||||
(value - 0.1).clamp(min, max).toStringAsFixed(1),
|
||||
onIncrease: onChanged != null
|
||||
? () => onChanged((value + 0.1).clamp(min, max))
|
||||
: null,
|
||||
onDecrease: onChanged != null
|
||||
? () => onChanged((value - 0.1).clamp(min, max))
|
||||
: null,
|
||||
child: Slider(
|
||||
value: value,
|
||||
min: min,
|
||||
max: max,
|
||||
divisions: divisions,
|
||||
onChanged: onChanged,
|
||||
label: formattedValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible modal with focus management
|
||||
static Future<T?> showAccessibleModal<T>({
|
||||
required BuildContext context,
|
||||
required Widget child,
|
||||
required String title,
|
||||
bool barrierDismissible = true,
|
||||
}) {
|
||||
return showDialog<T>(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
builder: (context) => Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
label: 'Dialog: $title',
|
||||
child: AlertDialog(
|
||||
title: Semantics(header: true, child: Text(title)),
|
||||
content: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Check color contrast ratio (simplified implementation)
|
||||
static bool hasGoodContrast(Color foreground, Color background) {
|
||||
// Simplified contrast calculation
|
||||
final fgLuminance = _getLuminance(foreground);
|
||||
final bgLuminance = _getLuminance(background);
|
||||
|
||||
final lighter = fgLuminance > bgLuminance ? fgLuminance : bgLuminance;
|
||||
final darker = fgLuminance > bgLuminance ? bgLuminance : fgLuminance;
|
||||
|
||||
final contrast = (lighter + 0.05) / (darker + 0.05);
|
||||
|
||||
// WCAG AA requires 4.5:1 for normal text, 3:1 for large text
|
||||
return contrast >= 4.5;
|
||||
}
|
||||
|
||||
/// Calculate relative luminance of a color
|
||||
static double _getLuminance(Color color) {
|
||||
final r = _gammaCorrect((color.r * 255.0).round() / 255.0);
|
||||
final g = _gammaCorrect((color.g * 255.0).round() / 255.0);
|
||||
final b = _gammaCorrect((color.b * 255.0).round() / 255.0);
|
||||
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/// Apply gamma correction
|
||||
static double _gammaCorrect(double value) {
|
||||
return value <= 0.03928
|
||||
? value / 12.92
|
||||
: math.pow((value + 0.055) / 1.055, 2.4).toDouble();
|
||||
}
|
||||
|
||||
/// Provide haptic feedback if available
|
||||
static void hapticFeedback() {
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
|
||||
/// Create accessible focus border
|
||||
static BoxDecoration createFocusBorder({
|
||||
required bool hasFocus,
|
||||
Color? focusColor,
|
||||
double borderWidth = 2.0,
|
||||
BorderRadius? borderRadius,
|
||||
}) {
|
||||
return BoxDecoration(
|
||||
border: hasFocus
|
||||
? Border.all(
|
||||
color:
|
||||
focusColor ??
|
||||
AppTheme.brandPrimary, // Brand primary as fallback
|
||||
width: borderWidth,
|
||||
)
|
||||
: null,
|
||||
borderRadius: borderRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create accessible text with proper scaling
|
||||
static Widget createAccessibleText(
|
||||
String text, {
|
||||
TextStyle? style,
|
||||
TextAlign? textAlign,
|
||||
bool isHeader = false,
|
||||
int? maxLines,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final textScaleFactor = getBoundedTextScaleFactor(context);
|
||||
|
||||
Widget textWidget = Text(
|
||||
text,
|
||||
style:
|
||||
style?.copyWith(
|
||||
fontSize: style.fontSize != null
|
||||
? style.fontSize! * textScaleFactor
|
||||
: null,
|
||||
) ??
|
||||
TextStyle(fontSize: AppTypography.bodyLarge * textScaleFactor),
|
||||
textAlign: textAlign,
|
||||
maxLines: maxLines,
|
||||
overflow: maxLines != null ? TextOverflow.ellipsis : null,
|
||||
);
|
||||
|
||||
if (isHeader) {
|
||||
textWidget = Semantics(header: true, child: textWidget);
|
||||
}
|
||||
|
||||
return textWidget;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
241
lib/core/services/error_handling_service.dart
Normal file
241
lib/core/services/error_handling_service.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
import '../../shared/widgets/themed_dialogs.dart';
|
||||
import 'user_friendly_error_handler.dart';
|
||||
|
||||
class ErrorHandlingService {
|
||||
static final _userFriendlyHandler = UserFriendlyErrorHandler();
|
||||
|
||||
static String getErrorMessage(dynamic error) {
|
||||
// Use the enhanced user-friendly error handler
|
||||
return _userFriendlyHandler.getUserMessage(error);
|
||||
}
|
||||
|
||||
/// Get recovery actions for an error
|
||||
static List<ErrorRecoveryAction> getRecoveryActions(dynamic error) {
|
||||
return _userFriendlyHandler.getRecoveryActions(error);
|
||||
}
|
||||
|
||||
static void showErrorSnackBar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
String? customMessage,
|
||||
}) {
|
||||
if (customMessage != null) {
|
||||
// Use custom message if provided
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(customMessage),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
action: onRetry != null
|
||||
? SnackBarAction(
|
||||
label: 'Retry',
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
onPressed: onRetry,
|
||||
)
|
||||
: null,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Use enhanced error handler
|
||||
_userFriendlyHandler.showErrorSnackbar(context, error, onRetry: onRetry);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show enhanced error dialog with recovery options
|
||||
static Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showDetails = false,
|
||||
}) async {
|
||||
return _userFriendlyHandler.showErrorDialog(
|
||||
context,
|
||||
error,
|
||||
onRetry: onRetry,
|
||||
showDetails: showDetails,
|
||||
);
|
||||
}
|
||||
|
||||
static void showSuccessSnackBar(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration? duration,
|
||||
}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: context.conduitTheme.success,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: duration ?? const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<bool> showConfirmationDialog(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = 'Confirm',
|
||||
String cancelText = 'Cancel',
|
||||
bool isDestructive = false,
|
||||
}) async {
|
||||
return await ThemedDialogs.confirm(
|
||||
context,
|
||||
title: title,
|
||||
message: content,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
isDestructive: isDestructive,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildErrorWidget({
|
||||
required String message,
|
||||
VoidCallback? onRetry,
|
||||
IconData? icon,
|
||||
dynamic error,
|
||||
}) {
|
||||
if (error != null) {
|
||||
// Use enhanced error handler for full error objects
|
||||
return _userFriendlyHandler.buildErrorWidget(error, onRetry: onRetry);
|
||||
}
|
||||
|
||||
// Fallback to legacy implementation for string messages
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.error_outline,
|
||||
size: Spacing.xxxl,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
ElevatedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Try Again'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build enhanced error widget with recovery actions
|
||||
static Widget buildEnhancedErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showDetails = false,
|
||||
}) {
|
||||
return _userFriendlyHandler.buildErrorWidget(
|
||||
error,
|
||||
onRetry: onRetry,
|
||||
onDismiss: onDismiss,
|
||||
showDetails: showDetails,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildLoadingWidget({String? message}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(color: theme.colorScheme.primary),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget buildEmptyStateWidget({
|
||||
required String title,
|
||||
required String message,
|
||||
IconData? icon,
|
||||
Widget? action,
|
||||
}) {
|
||||
return Builder(
|
||||
builder: (context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon ?? Icons.inbox_outlined,
|
||||
size: Spacing.xxxl,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
message,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: Spacing.lg),
|
||||
action,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
373
lib/core/services/error_recovery_service.dart
Normal file
373
lib/core/services/error_recovery_service.dart
Normal file
@@ -0,0 +1,373 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Enhanced error recovery service with retry strategies and user feedback
|
||||
class ErrorRecoveryService {
|
||||
final Map<String, RetryConfig> _retryConfigs = {};
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
|
||||
ErrorRecoveryService(Dio dio);
|
||||
|
||||
/// Execute an operation with automatic retry and recovery
|
||||
Future<T> executeWithRecovery<T>({
|
||||
required String operationId,
|
||||
required Future<T> Function() operation,
|
||||
RetryConfig? retryConfig,
|
||||
RecoveryAction? recoveryAction,
|
||||
}) async {
|
||||
final config = retryConfig ?? RetryConfig.defaultConfig();
|
||||
_retryConfigs[operationId] = config;
|
||||
|
||||
int attempts = 0;
|
||||
Exception? lastError;
|
||||
|
||||
while (attempts < config.maxRetries) {
|
||||
try {
|
||||
final result = await operation();
|
||||
_clearRetryState(operationId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
lastError = error is Exception ? error : Exception(error.toString());
|
||||
|
||||
final shouldRetry = _shouldRetry(error, attempts, config);
|
||||
if (!shouldRetry || attempts >= config.maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Execute recovery action if provided
|
||||
if (recoveryAction != null) {
|
||||
try {
|
||||
await recoveryAction.execute(error, attempts);
|
||||
} catch (recoveryError) {
|
||||
// Recovery action failed, continue with retry
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before retry with exponential backoff
|
||||
final delay = _calculateRetryDelay(attempts, config);
|
||||
await Future.delayed(delay);
|
||||
}
|
||||
}
|
||||
|
||||
_clearRetryState(operationId);
|
||||
throw ErrorRecoveryException(lastError!, attempts);
|
||||
}
|
||||
|
||||
/// Check if we should retry based on error type and configuration
|
||||
bool _shouldRetry(dynamic error, int attempts, RetryConfig config) {
|
||||
if (attempts >= config.maxRetries) return false;
|
||||
|
||||
// Check cooldown period
|
||||
final lastRetry = _lastRetryTimes[config.operationId];
|
||||
if (lastRetry != null) {
|
||||
final timeSinceLastRetry = DateTime.now().difference(lastRetry);
|
||||
if (timeSinceLastRetry < config.cooldownPeriod) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Network errors are usually retryable
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.badResponse:
|
||||
// Retry on server errors (5xx) but not client errors (4xx)
|
||||
final statusCode = error.response?.statusCode;
|
||||
return statusCode != null && statusCode >= 500;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check custom retry conditions
|
||||
return config.retryCondition?.call(error) ?? false;
|
||||
}
|
||||
|
||||
Duration _calculateRetryDelay(int attempt, RetryConfig config) {
|
||||
if (config.retryStrategy == RetryStrategy.exponentialBackoff) {
|
||||
final baseDelay = config.baseDelay.inMilliseconds;
|
||||
final delay = baseDelay * pow(2, attempt - 1);
|
||||
final jitter = Random().nextDouble() * 0.1 * delay; // Add 10% jitter
|
||||
return Duration(milliseconds: (delay + jitter).round());
|
||||
} else {
|
||||
return config.baseDelay;
|
||||
}
|
||||
}
|
||||
|
||||
void _clearRetryState(String operationId) {
|
||||
_retryConfigs.remove(operationId);
|
||||
_lastRetryTimes.remove(operationId);
|
||||
}
|
||||
|
||||
/// Get user-friendly error message
|
||||
String getErrorMessage(dynamic error) {
|
||||
if (error is ErrorRecoveryException) {
|
||||
return _getRecoveryErrorMessage(error);
|
||||
}
|
||||
|
||||
if (error is DioException) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return 'The connection is taking too long. Please check your internet and try again.';
|
||||
case DioExceptionType.sendTimeout:
|
||||
return 'Failed to send your request. Please try again.';
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'The server is taking too long to respond. Please try again.';
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Unable to connect. Please check your internet connection.';
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode == 401) {
|
||||
return 'Your session has expired. Please sign in again.';
|
||||
} else if (statusCode == 403) {
|
||||
return 'You don\'t have permission to perform this action.';
|
||||
} else if (statusCode == 404) {
|
||||
return 'The requested resource was not found.';
|
||||
} else if (statusCode != null && statusCode >= 500) {
|
||||
return 'The server is experiencing issues. Please try again later.';
|
||||
}
|
||||
return 'Something went wrong with your request.';
|
||||
case DioExceptionType.cancel:
|
||||
return 'The request was cancelled.';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'There\'s a security issue with the connection.';
|
||||
case DioExceptionType.unknown:
|
||||
return 'Something unexpected happened. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
String _getRecoveryErrorMessage(ErrorRecoveryException error) {
|
||||
final attempts = error.attempts;
|
||||
final originalError = getErrorMessage(error.originalError);
|
||||
|
||||
return 'Failed after $attempts attempts: $originalError';
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for retry behavior
|
||||
class RetryConfig {
|
||||
final String operationId;
|
||||
final int maxRetries;
|
||||
final Duration baseDelay;
|
||||
final Duration cooldownPeriod;
|
||||
final RetryStrategy retryStrategy;
|
||||
final bool Function(dynamic error)? retryCondition;
|
||||
|
||||
const RetryConfig({
|
||||
required this.operationId,
|
||||
this.maxRetries = 3,
|
||||
this.baseDelay = const Duration(seconds: 1),
|
||||
this.cooldownPeriod = const Duration(seconds: 5),
|
||||
this.retryStrategy = RetryStrategy.exponentialBackoff,
|
||||
this.retryCondition,
|
||||
});
|
||||
|
||||
static RetryConfig defaultConfig() => const RetryConfig(
|
||||
operationId: 'default',
|
||||
maxRetries: 3,
|
||||
baseDelay: Duration(seconds: 1),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
|
||||
static RetryConfig networkConfig() => const RetryConfig(
|
||||
operationId: 'network',
|
||||
maxRetries: 5,
|
||||
baseDelay: Duration(milliseconds: 500),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
|
||||
static RetryConfig chatConfig() => const RetryConfig(
|
||||
operationId: 'chat',
|
||||
maxRetries: 3,
|
||||
baseDelay: Duration(seconds: 2),
|
||||
retryStrategy: RetryStrategy.exponentialBackoff,
|
||||
);
|
||||
}
|
||||
|
||||
enum RetryStrategy { fixed, exponentialBackoff }
|
||||
|
||||
/// Recovery action to execute between retries
|
||||
abstract class RecoveryAction {
|
||||
Future<void> execute(dynamic error, int attempt);
|
||||
}
|
||||
|
||||
/// Reconnect to server recovery action
|
||||
class ReconnectAction extends RecoveryAction {
|
||||
final Future<void> Function() reconnectFunction;
|
||||
|
||||
ReconnectAction(this.reconnectFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (attempt == 1) {
|
||||
// Only try to reconnect on the first retry
|
||||
await reconnectFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh token recovery action
|
||||
class RefreshTokenAction extends RecoveryAction {
|
||||
final Future<void> Function() refreshFunction;
|
||||
|
||||
RefreshTokenAction(this.refreshFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (error is DioException && error.response?.statusCode == 401) {
|
||||
await refreshFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache recovery action
|
||||
class ClearCacheAction extends RecoveryAction {
|
||||
final Future<void> Function() clearCacheFunction;
|
||||
|
||||
ClearCacheAction(this.clearCacheFunction);
|
||||
|
||||
@override
|
||||
Future<void> execute(dynamic error, int attempt) async {
|
||||
if (attempt == 2) {
|
||||
// Clear cache on second attempt
|
||||
await clearCacheFunction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error recovery exception
|
||||
class ErrorRecoveryException implements Exception {
|
||||
final Exception originalError;
|
||||
final int attempts;
|
||||
|
||||
const ErrorRecoveryException(this.originalError, this.attempts);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ErrorRecoveryException: $originalError (after $attempts attempts)';
|
||||
}
|
||||
|
||||
/// Providers
|
||||
final errorRecoveryServiceProvider = Provider<ErrorRecoveryService>((ref) {
|
||||
// This should use the same Dio instance as the API service
|
||||
final dio = Dio(); // Replace with actual Dio provider
|
||||
return ErrorRecoveryService(dio);
|
||||
});
|
||||
|
||||
/// Error boundary widget for handling UI errors
|
||||
class ErrorBoundary extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Widget Function(Object error, VoidCallback retry)? errorBuilder;
|
||||
final void Function(Object error, StackTrace stackTrace)? onError;
|
||||
|
||||
const ErrorBoundary({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.errorBuilder,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ErrorBoundary> createState() => _ErrorBoundaryState();
|
||||
}
|
||||
|
||||
class _ErrorBoundaryState extends State<ErrorBoundary> {
|
||||
Object? error;
|
||||
StackTrace? stackTrace;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (error != null) {
|
||||
return widget.errorBuilder?.call(error!, _retry) ??
|
||||
_buildDefaultErrorWidget();
|
||||
}
|
||||
|
||||
return ErrorDetector(
|
||||
onError: (error, stackTrace) {
|
||||
setState(() {
|
||||
this.error = error;
|
||||
this.stackTrace = stackTrace;
|
||||
});
|
||||
widget.onError?.call(error, stackTrace);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultErrorWidget() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: Spacing.xxxl,
|
||||
color: context.conduitTheme.error,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
const Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: AppTypography.headlineSmall,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.sm),
|
||||
Text(
|
||||
error.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
ElevatedButton(onPressed: _retry, child: const Text('Try Again')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _retry() {
|
||||
setState(() {
|
||||
error = null;
|
||||
stackTrace = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to detect and handle errors in child widgets
|
||||
class ErrorDetector extends StatefulWidget {
|
||||
final Widget child;
|
||||
final void Function(Object error, StackTrace stackTrace) onError;
|
||||
|
||||
const ErrorDetector({super.key, required this.child, required this.onError});
|
||||
|
||||
@override
|
||||
State<ErrorDetector> createState() => _ErrorDetectorState();
|
||||
}
|
||||
|
||||
class _ErrorDetectorState extends State<ErrorDetector> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Set up error handling
|
||||
FlutterError.onError = (details) {
|
||||
widget.onError(details.exception, details.stack ?? StackTrace.current);
|
||||
};
|
||||
}
|
||||
}
|
||||
408
lib/core/services/focus_management_service.dart
Normal file
408
lib/core/services/focus_management_service.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/semantics.dart';
|
||||
|
||||
/// Comprehensive focus management service for accessibility
|
||||
class FocusManagementService {
|
||||
static final Map<String, FocusNode> _focusNodes = {};
|
||||
static final Map<String, FocusNode> _disposedNodes = {};
|
||||
static FocusNode? _lastFocusedNode;
|
||||
static final List<FocusNode> _focusHistory = [];
|
||||
|
||||
/// Register a focus node with a unique identifier
|
||||
static FocusNode registerFocusNode(
|
||||
String identifier, {
|
||||
String? debugLabel,
|
||||
FocusOnKeyEventCallback? onKeyEvent,
|
||||
bool skipTraversal = false,
|
||||
bool canRequestFocus = true,
|
||||
}) {
|
||||
// Check if node already exists
|
||||
if (_focusNodes.containsKey(identifier)) {
|
||||
return _focusNodes[identifier]!;
|
||||
}
|
||||
|
||||
// Create new focus node
|
||||
final focusNode = FocusNode(
|
||||
debugLabel: debugLabel ?? identifier,
|
||||
onKeyEvent: onKeyEvent,
|
||||
skipTraversal: skipTraversal,
|
||||
canRequestFocus: canRequestFocus,
|
||||
);
|
||||
|
||||
// Add listener to track focus changes
|
||||
focusNode.addListener(() {
|
||||
if (focusNode.hasFocus) {
|
||||
_onFocusChanged(focusNode);
|
||||
}
|
||||
});
|
||||
|
||||
_focusNodes[identifier] = focusNode;
|
||||
return focusNode;
|
||||
}
|
||||
|
||||
/// Get a registered focus node
|
||||
static FocusNode? getFocusNode(String identifier) {
|
||||
return _focusNodes[identifier];
|
||||
}
|
||||
|
||||
/// Dispose a focus node
|
||||
static void disposeFocusNode(String identifier) {
|
||||
final node = _focusNodes.remove(identifier);
|
||||
if (node != null) {
|
||||
_disposedNodes[identifier] = node;
|
||||
node.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all focus nodes
|
||||
static void disposeAll() {
|
||||
for (final node in _focusNodes.values) {
|
||||
node.dispose();
|
||||
}
|
||||
_focusNodes.clear();
|
||||
_focusHistory.clear();
|
||||
_lastFocusedNode = null;
|
||||
}
|
||||
|
||||
/// Request focus for a specific node
|
||||
static void requestFocus(String identifier) {
|
||||
final node = _focusNodes[identifier];
|
||||
if (node != null && node.canRequestFocus) {
|
||||
node.requestFocus();
|
||||
HapticFeedback.selectionClick();
|
||||
}
|
||||
}
|
||||
|
||||
/// Unfocus current focus
|
||||
static void unfocus(
|
||||
BuildContext context, {
|
||||
UnfocusDisposition disposition = UnfocusDisposition.scope,
|
||||
}) {
|
||||
FocusScope.of(context).unfocus(disposition: disposition);
|
||||
}
|
||||
|
||||
/// Move focus to next focusable element
|
||||
static bool nextFocus(BuildContext context) {
|
||||
return FocusScope.of(context).nextFocus();
|
||||
}
|
||||
|
||||
/// Move focus to previous focusable element
|
||||
static bool previousFocus(BuildContext context) {
|
||||
return FocusScope.of(context).previousFocus();
|
||||
}
|
||||
|
||||
/// Track focus changes
|
||||
static void _onFocusChanged(FocusNode node) {
|
||||
_lastFocusedNode = node;
|
||||
_focusHistory.add(node);
|
||||
|
||||
// Limit history size
|
||||
if (_focusHistory.length > 10) {
|
||||
_focusHistory.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore last focus
|
||||
static void restoreLastFocus() {
|
||||
if (_lastFocusedNode != null && _lastFocusedNode!.canRequestFocus) {
|
||||
_lastFocusedNode!.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Get focus history
|
||||
static List<FocusNode> getFocusHistory() {
|
||||
return List.unmodifiable(_focusHistory);
|
||||
}
|
||||
|
||||
/// Create a focus trap for modal dialogs
|
||||
static Widget createFocusTrap({
|
||||
required Widget child,
|
||||
bool autofocus = true,
|
||||
}) {
|
||||
return FocusScope(autofocus: autofocus, child: child);
|
||||
}
|
||||
|
||||
/// Create keyboard navigation handler
|
||||
static FocusOnKeyEventCallback createKeyboardNavigationHandler({
|
||||
VoidCallback? onEnter,
|
||||
VoidCallback? onEscape,
|
||||
VoidCallback? onTab,
|
||||
VoidCallback? onArrowUp,
|
||||
VoidCallback? onArrowDown,
|
||||
VoidCallback? onArrowLeft,
|
||||
VoidCallback? onArrowRight,
|
||||
}) {
|
||||
return (FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
onEnter?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.escape) {
|
||||
onEscape?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
onTab?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
onArrowUp?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
onArrowDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
onArrowLeft?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
onArrowRight?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Focus manager widget that manages focus for its children
|
||||
class FocusManager extends StatefulWidget {
|
||||
final Widget child;
|
||||
final bool autofocus;
|
||||
final bool trapFocus;
|
||||
final FocusOnKeyEventCallback? onKeyEvent;
|
||||
|
||||
const FocusManager({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.autofocus = false,
|
||||
this.trapFocus = false,
|
||||
this.onKeyEvent,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FocusManager> createState() => _FocusManagerState();
|
||||
}
|
||||
|
||||
class _FocusManagerState extends State<FocusManager> {
|
||||
late FocusScopeNode _focusScopeNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusScopeNode = FocusScopeNode(
|
||||
debugLabel: 'FocusManager',
|
||||
onKeyEvent: widget.onKeyEvent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusScopeNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = FocusScope(
|
||||
node: _focusScopeNode,
|
||||
autofocus: widget.autofocus,
|
||||
child: widget.child,
|
||||
);
|
||||
|
||||
if (widget.trapFocus) {
|
||||
child = FocusTraversalGroup(
|
||||
policy: OrderedTraversalPolicy(),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
/// Accessible form field with proper focus management
|
||||
class AccessibleFormField extends StatefulWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?)? validator;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final bool autofocus;
|
||||
final String? semanticLabel;
|
||||
final String? errorSemanticLabel;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final VoidCallback? onEditingComplete;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final int? maxLines;
|
||||
final int? maxLength;
|
||||
final bool enabled;
|
||||
final Widget? suffixIcon;
|
||||
final Widget? prefixIcon;
|
||||
final FocusNode? focusNode;
|
||||
|
||||
const AccessibleFormField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
this.validator,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.autofocus = false,
|
||||
this.semanticLabel,
|
||||
this.errorSemanticLabel,
|
||||
this.onChanged,
|
||||
this.onEditingComplete,
|
||||
this.onSubmitted,
|
||||
this.inputFormatters,
|
||||
this.maxLines = 1,
|
||||
this.maxLength,
|
||||
this.enabled = true,
|
||||
this.suffixIcon,
|
||||
this.prefixIcon,
|
||||
this.focusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AccessibleFormField> createState() => _AccessibleFormFieldState();
|
||||
}
|
||||
|
||||
class _AccessibleFormFieldState extends State<AccessibleFormField> {
|
||||
late FocusNode _focusNode;
|
||||
String? _errorText;
|
||||
bool _hasFocus = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode = widget.focusNode ?? FocusNode(debugLabel: widget.label);
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocusChanged() {
|
||||
setState(() {
|
||||
_hasFocus = _focusNode.hasFocus;
|
||||
});
|
||||
|
||||
// Announce focus change for screen readers
|
||||
if (_hasFocus) {
|
||||
final announcement =
|
||||
widget.semanticLabel ??
|
||||
'${widget.label} text field. ${widget.hint ?? ''}';
|
||||
SemanticsService.announce(announcement, TextDirection.ltr);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Semantics(
|
||||
label: widget.semanticLabel ?? widget.label,
|
||||
hint: widget.hint,
|
||||
textField: true,
|
||||
enabled: widget.enabled,
|
||||
focusable: true,
|
||||
focused: _hasFocus,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Label
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: _hasFocus
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurface,
|
||||
fontWeight: _hasFocus ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Text field
|
||||
TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
validator: (value) {
|
||||
final error = widget.validator?.call(value);
|
||||
setState(() {
|
||||
_errorText = error;
|
||||
});
|
||||
|
||||
// Announce error for screen readers
|
||||
if (error != null) {
|
||||
final errorAnnouncement =
|
||||
widget.errorSemanticLabel ?? 'Error: $error';
|
||||
SemanticsService.announce(errorAnnouncement, TextDirection.ltr);
|
||||
}
|
||||
|
||||
return error;
|
||||
},
|
||||
keyboardType: widget.keyboardType,
|
||||
obscureText: widget.obscureText,
|
||||
autofocus: widget.autofocus,
|
||||
onChanged: widget.onChanged,
|
||||
onEditingComplete: widget.onEditingComplete,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
maxLines: widget.maxLines,
|
||||
maxLength: widget.maxLength,
|
||||
enabled: widget.enabled,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
errorText: _errorText,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
prefixIcon: widget.prefixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
457
lib/core/services/input_validation_service.dart
Normal file
457
lib/core/services/input_validation_service.dart
Normal file
@@ -0,0 +1,457 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Comprehensive input validation service
|
||||
class InputValidationService {
|
||||
// Email regex pattern
|
||||
static final RegExp _emailRegex = RegExp(
|
||||
r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
|
||||
);
|
||||
|
||||
// Strong password regex (min 8 chars, 1 upper, 1 lower, 1 number, 1 special)
|
||||
static final RegExp _strongPasswordRegex = RegExp(
|
||||
r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$',
|
||||
);
|
||||
|
||||
/// Validate email address
|
||||
static String? validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email is required';
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
if (!_emailRegex.hasMatch(trimmed)) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate URL
|
||||
static String? validateUrl(String? value, {bool required = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'URL is required' : null;
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
|
||||
// Add protocol if missing
|
||||
String urlToValidate = trimmed;
|
||||
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||
urlToValidate = 'https://$trimmed';
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(urlToValidate);
|
||||
if (!uri.hasScheme || !uri.hasAuthority) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
} catch (e) {
|
||||
return 'Please enter a valid URL';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate password strength
|
||||
static String? validatePassword(String? value, {bool checkStrength = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Password is required';
|
||||
}
|
||||
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
|
||||
if (checkStrength && !_strongPasswordRegex.hasMatch(value)) {
|
||||
return 'Password must contain uppercase, lowercase, number, and special character';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate confirm password
|
||||
static String? validateConfirmPassword(String? value, String password) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please confirm your password';
|
||||
}
|
||||
|
||||
if (value != password) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate required field
|
||||
static String? validateRequired(
|
||||
String? value, {
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '$fieldName is required';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate minimum length
|
||||
static String? validateMinLength(
|
||||
String? value,
|
||||
int minLength, {
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '$fieldName is required';
|
||||
}
|
||||
|
||||
if (value.length < minLength) {
|
||||
return '$fieldName must be at least $minLength characters';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate maximum length
|
||||
static String? validateMaxLength(
|
||||
String? value,
|
||||
int maxLength, {
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value != null && value.length > maxLength) {
|
||||
return '$fieldName must be at most $maxLength characters';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate numeric input
|
||||
static String? validateNumber(
|
||||
String? value, {
|
||||
double? min,
|
||||
double? max,
|
||||
bool allowDecimal = true,
|
||||
bool required = true,
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'Number is required' : null;
|
||||
}
|
||||
|
||||
final number = allowDecimal ? double.tryParse(value) : int.tryParse(value);
|
||||
|
||||
if (number == null) {
|
||||
return allowDecimal
|
||||
? 'Please enter a valid number'
|
||||
: 'Please enter a whole number';
|
||||
}
|
||||
|
||||
if (min != null && number < min) {
|
||||
return 'Value must be at least $min';
|
||||
}
|
||||
|
||||
if (max != null && number > max) {
|
||||
return 'Value must be at most $max';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate phone number
|
||||
static String? validatePhoneNumber(String? value, {bool required = true}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? 'Phone number is required' : null;
|
||||
}
|
||||
|
||||
// Remove all non-digits
|
||||
final digitsOnly = value.replaceAll(RegExp(r'\D'), '');
|
||||
|
||||
if (digitsOnly.length < 10) {
|
||||
return 'Please enter a valid phone number';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate alphanumeric input
|
||||
static String? validateAlphanumeric(
|
||||
String? value, {
|
||||
bool allowSpaces = false,
|
||||
bool required = true,
|
||||
String fieldName = 'This field',
|
||||
}) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return required ? '$fieldName is required' : null;
|
||||
}
|
||||
|
||||
final pattern = allowSpaces ? r'^[a-zA-Z0-9\s]+$' : r'^[a-zA-Z0-9]+$';
|
||||
if (!RegExp(pattern).hasMatch(value)) {
|
||||
return allowSpaces
|
||||
? '$fieldName can only contain letters, numbers, and spaces'
|
||||
: '$fieldName can only contain letters and numbers';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate username
|
||||
static String? validateUsername(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Username is required';
|
||||
}
|
||||
|
||||
if (value.length < 3) {
|
||||
return 'Username must be at least 3 characters';
|
||||
}
|
||||
|
||||
if (value.length > 20) {
|
||||
return 'Username must be at most 20 characters';
|
||||
}
|
||||
|
||||
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
|
||||
return 'Username can only contain letters, numbers, and underscores';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate email or username (flexible login)
|
||||
static String? validateEmailOrUsername(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email or username is required';
|
||||
}
|
||||
|
||||
final trimmed = value.trim();
|
||||
|
||||
// If it contains @ symbol, validate as email
|
||||
if (trimmed.contains('@')) {
|
||||
return validateEmail(value);
|
||||
}
|
||||
|
||||
// Otherwise validate as username
|
||||
return validateUsername(value);
|
||||
}
|
||||
|
||||
/// Sanitize input to prevent XSS
|
||||
static String sanitizeInput(String input) {
|
||||
return input
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
.replaceAll('/', '/');
|
||||
}
|
||||
|
||||
/// Create input formatter for numeric input
|
||||
static List<TextInputFormatter> numericInputFormatters({
|
||||
bool allowDecimal = true,
|
||||
bool allowNegative = false,
|
||||
}) {
|
||||
return [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(
|
||||
allowDecimal
|
||||
? (allowNegative ? r'[0-9.-]' : r'[0-9.]')
|
||||
: (allowNegative ? r'[0-9-]' : r'[0-9]'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Create input formatter for alphanumeric input
|
||||
static List<TextInputFormatter> alphanumericInputFormatters({
|
||||
bool allowSpaces = false,
|
||||
}) {
|
||||
return [
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp(allowSpaces ? r'[a-zA-Z0-9\s]' : r'[a-zA-Z0-9]'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Create input formatter for phone number
|
||||
static List<TextInputFormatter> phoneNumberFormatters() {
|
||||
return [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(15),
|
||||
_PhoneNumberFormatter(),
|
||||
];
|
||||
}
|
||||
|
||||
/// Validate file size
|
||||
static String? validateFileSize(int sizeInBytes, {int maxSizeInMB = 10}) {
|
||||
final maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
||||
if (sizeInBytes > maxSizeInBytes) {
|
||||
return 'File size must be less than ${maxSizeInMB}MB';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate file extension
|
||||
static String? validateFileExtension(
|
||||
String fileName,
|
||||
List<String> allowedExtensions,
|
||||
) {
|
||||
final extension = fileName.split('.').last.toLowerCase();
|
||||
if (!allowedExtensions.contains(extension)) {
|
||||
return 'File type not allowed. Allowed types: ${allowedExtensions.join(', ')}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Composite validator that runs multiple validators
|
||||
static String? Function(String?) combine(
|
||||
List<String? Function(String?)> validators,
|
||||
) {
|
||||
return (String? value) {
|
||||
for (final validator in validators) {
|
||||
final result = validator(value);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom phone number formatter
|
||||
class _PhoneNumberFormatter extends TextInputFormatter {
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
final text = newValue.text;
|
||||
|
||||
if (text.length <= 3) {
|
||||
return newValue;
|
||||
}
|
||||
|
||||
if (text.length <= 6) {
|
||||
final newText = '(${text.substring(0, 3)}) ${text.substring(3)}';
|
||||
return TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newText.length),
|
||||
);
|
||||
}
|
||||
|
||||
if (text.length <= 10) {
|
||||
final newText =
|
||||
'(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6)}';
|
||||
return TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newText.length),
|
||||
);
|
||||
}
|
||||
|
||||
final newText =
|
||||
'(${text.substring(0, 3)}) ${text.substring(3, 6)}-${text.substring(6, 10)}';
|
||||
return TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: newText.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Form field wrapper with validation
|
||||
class ValidatedFormField extends StatefulWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?) validator;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final Widget? suffixIcon;
|
||||
final bool autofocus;
|
||||
final void Function(String)? onChanged;
|
||||
final void Function(String)? onFieldSubmitted;
|
||||
final FocusNode? focusNode;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
|
||||
const ValidatedFormField({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
required this.controller,
|
||||
required this.validator,
|
||||
this.inputFormatters,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.suffixIcon,
|
||||
this.autofocus = false,
|
||||
this.onChanged,
|
||||
this.onFieldSubmitted,
|
||||
this.focusNode,
|
||||
this.maxLines = 1,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ValidatedFormField> createState() => _ValidatedFormFieldState();
|
||||
}
|
||||
|
||||
class _ValidatedFormFieldState extends State<ValidatedFormField> {
|
||||
String? _errorText;
|
||||
bool _hasInteracted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_validate);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_validate);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _validate() {
|
||||
if (!_hasInteracted) return;
|
||||
|
||||
final error = widget.validator(widget.controller.text);
|
||||
if (error != _errorText) {
|
||||
setState(() {
|
||||
_errorText = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
validator: (value) {
|
||||
setState(() {
|
||||
_hasInteracted = true;
|
||||
});
|
||||
return widget.validator(value);
|
||||
},
|
||||
inputFormatters: widget.inputFormatters,
|
||||
keyboardType: widget.keyboardType,
|
||||
obscureText: widget.obscureText,
|
||||
autofocus: widget.autofocus,
|
||||
maxLines: widget.maxLines,
|
||||
enabled: widget.enabled,
|
||||
onChanged: (value) {
|
||||
if (!_hasInteracted) {
|
||||
setState(() {
|
||||
_hasInteracted = true;
|
||||
});
|
||||
}
|
||||
_validate();
|
||||
widget.onChanged?.call(value);
|
||||
},
|
||||
onFieldSubmitted: widget.onFieldSubmitted,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
errorText: _errorText,
|
||||
suffixIcon: widget.suffixIcon,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
250
lib/core/services/navigation_service.dart
Normal file
250
lib/core/services/navigation_service.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
// ThemedDialogs handles theming; no direct use of extensions here
|
||||
import '../../features/chat/views/chat_page.dart';
|
||||
import '../../features/auth/views/connect_signin_page.dart';
|
||||
import '../../features/settings/views/searchable_settings_page.dart';
|
||||
import '../../features/profile/views/profile_page.dart';
|
||||
import '../../features/files/views/files_page.dart';
|
||||
import '../../features/chat/views/conversation_search_page.dart';
|
||||
import '../../shared/widgets/themed_dialogs.dart';
|
||||
|
||||
import '../../features/navigation/views/chats_list_page.dart';
|
||||
|
||||
/// Centralized navigation service to handle all routing logic
|
||||
/// Prevents navigation stack issues and memory leaks
|
||||
class NavigationService {
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
|
||||
static NavigatorState? get navigator => navigatorKey.currentState;
|
||||
static BuildContext? get context => navigatorKey.currentContext;
|
||||
|
||||
// Navigation stack tracking for analytics and debugging
|
||||
static final List<String> _navigationStack = [];
|
||||
static List<String> get navigationStack =>
|
||||
List.unmodifiable(_navigationStack);
|
||||
|
||||
// Prevent duplicate navigation
|
||||
static String? _currentRoute;
|
||||
static bool _isNavigating = false;
|
||||
static DateTime? _lastNavigationTime;
|
||||
|
||||
/// Navigate to a named route with optional arguments
|
||||
static Future<T?> navigateTo<T>(
|
||||
String routeName, {
|
||||
Object? arguments,
|
||||
bool replace = false,
|
||||
bool clearStack = false,
|
||||
}) async {
|
||||
// Only block if we're already navigating to the exact same route
|
||||
// Allow navigation to different routes even if currently navigating
|
||||
if (_isNavigating && _currentRoute == routeName) {
|
||||
debugPrint('Navigation blocked: Already navigating to same route');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prevent rapid successive navigation attempts
|
||||
final now = DateTime.now();
|
||||
if (_lastNavigationTime != null &&
|
||||
now.difference(_lastNavigationTime!).inMilliseconds < 300) {
|
||||
debugPrint('Navigation blocked: Too rapid navigation attempts');
|
||||
return null;
|
||||
}
|
||||
|
||||
_isNavigating = true;
|
||||
|
||||
try {
|
||||
// Add haptic feedback for navigation
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
// Track navigation
|
||||
if (!replace && !clearStack) {
|
||||
_navigationStack.add(routeName);
|
||||
}
|
||||
_currentRoute = routeName;
|
||||
|
||||
if (clearStack) {
|
||||
_navigationStack.clear();
|
||||
_navigationStack.add(routeName);
|
||||
return await navigator?.pushNamedAndRemoveUntil<T>(
|
||||
routeName,
|
||||
(route) => false,
|
||||
arguments: arguments,
|
||||
);
|
||||
} else if (replace) {
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack.removeLast();
|
||||
}
|
||||
_navigationStack.add(routeName);
|
||||
return await navigator?.pushReplacementNamed<T, T>(
|
||||
routeName,
|
||||
arguments: arguments,
|
||||
);
|
||||
} else {
|
||||
return await navigator?.pushNamed<T>(routeName, arguments: arguments);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Navigation error: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
_isNavigating = false;
|
||||
_lastNavigationTime = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate back with optional result
|
||||
static void goBack<T>([T? result]) {
|
||||
if (navigator?.canPop() == true) {
|
||||
HapticFeedback.lightImpact();
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack.removeLast();
|
||||
}
|
||||
_currentRoute = _navigationStack.isEmpty ? null : _navigationStack.last;
|
||||
navigator?.pop<T>(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if can navigate back
|
||||
static bool canGoBack() {
|
||||
return navigator?.canPop() == true;
|
||||
}
|
||||
|
||||
/// Show confirmation dialog before navigation
|
||||
static Future<bool> confirmNavigation({
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmText = 'Continue',
|
||||
String cancelText = 'Cancel',
|
||||
}) async {
|
||||
if (context == null) return false;
|
||||
|
||||
final result = await ThemedDialogs.confirm(
|
||||
context!,
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: confirmText,
|
||||
cancelText: cancelText,
|
||||
barrierDismissible: false,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Removed tabbed main navigation
|
||||
|
||||
/// Navigate to chat
|
||||
static Future<void> navigateToChat({String? conversationId}) {
|
||||
return navigateTo(
|
||||
Routes.chat,
|
||||
arguments: {'conversationId': conversationId},
|
||||
replace: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Navigate to login
|
||||
static Future<void> navigateToLogin() {
|
||||
return navigateTo(Routes.login, clearStack: true);
|
||||
}
|
||||
|
||||
/// Navigate to settings
|
||||
static Future<void> navigateToSettings() {
|
||||
return navigateTo(Routes.settings);
|
||||
}
|
||||
|
||||
/// Navigate to profile
|
||||
static Future<void> navigateToProfile() {
|
||||
return navigateTo(Routes.profile);
|
||||
}
|
||||
|
||||
/// Navigate to server connection
|
||||
static Future<void> navigateToServerConnection() {
|
||||
return navigateTo(Routes.serverConnection);
|
||||
}
|
||||
|
||||
/// Navigate to search
|
||||
static Future<void> navigateToSearch() {
|
||||
return navigateTo(Routes.search);
|
||||
}
|
||||
|
||||
/// Navigate to chats list
|
||||
static Future<void> navigateToChatsList() {
|
||||
return navigateTo(Routes.chatsList);
|
||||
}
|
||||
|
||||
/// Clear navigation stack (useful for logout)
|
||||
static void clearNavigationStack() {
|
||||
_navigationStack.clear();
|
||||
_currentRoute = null;
|
||||
}
|
||||
|
||||
/// Set current route (useful for initial app state)
|
||||
static void setCurrentRoute(String routeName) {
|
||||
_currentRoute = routeName;
|
||||
if (!_navigationStack.contains(routeName)) {
|
||||
_navigationStack.add(routeName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate routes
|
||||
static Route<dynamic>? generateRoute(RouteSettings settings) {
|
||||
Widget page;
|
||||
|
||||
switch (settings.name) {
|
||||
// Removed tabbed main navigation
|
||||
|
||||
case Routes.chat:
|
||||
page = const ChatPage();
|
||||
break;
|
||||
|
||||
case Routes.login:
|
||||
page = const ConnectAndSignInPage();
|
||||
break;
|
||||
|
||||
case Routes.settings:
|
||||
page = const SearchableSettingsPage();
|
||||
break;
|
||||
|
||||
case Routes.profile:
|
||||
page = const ProfilePage();
|
||||
break;
|
||||
|
||||
case Routes.serverConnection:
|
||||
page = const ConnectAndSignInPage();
|
||||
break;
|
||||
|
||||
case Routes.search:
|
||||
page = const ConversationSearchPage();
|
||||
break;
|
||||
|
||||
case Routes.files:
|
||||
page = const FilesPage();
|
||||
break;
|
||||
|
||||
case Routes.chatsList:
|
||||
page = const ChatsListPage();
|
||||
break;
|
||||
|
||||
// Removed navigation drawer route
|
||||
|
||||
default:
|
||||
page = Scaffold(
|
||||
body: Center(child: Text('Route not found: ${settings.name}')),
|
||||
);
|
||||
}
|
||||
|
||||
return MaterialPageRoute(builder: (_) => page, settings: settings);
|
||||
}
|
||||
}
|
||||
|
||||
/// Route names
|
||||
class Routes {
|
||||
static const String chat = '/chat';
|
||||
static const String login = '/login';
|
||||
static const String settings = '/settings';
|
||||
static const String profile = '/profile';
|
||||
static const String serverConnection = '/server-connection';
|
||||
static const String search = '/search';
|
||||
static const String files = '/files';
|
||||
static const String chatsList = '/chats-list';
|
||||
}
|
||||
427
lib/core/services/navigation_state_service.dart
Normal file
427
lib/core/services/navigation_state_service.dart
Normal file
@@ -0,0 +1,427 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Navigation state data model
|
||||
class NavigationState {
|
||||
final String routeName;
|
||||
final Map<String, dynamic> arguments;
|
||||
final DateTime timestamp;
|
||||
final String? conversationId;
|
||||
final int? tabIndex;
|
||||
|
||||
NavigationState({
|
||||
required this.routeName,
|
||||
this.arguments = const {},
|
||||
DateTime? timestamp,
|
||||
this.conversationId,
|
||||
this.tabIndex,
|
||||
}) : timestamp = timestamp ?? DateTime.now();
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'routeName': routeName,
|
||||
'arguments': arguments,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
'conversationId': conversationId,
|
||||
'tabIndex': tabIndex,
|
||||
};
|
||||
|
||||
factory NavigationState.fromJson(Map<String, dynamic> json) {
|
||||
return NavigationState(
|
||||
routeName: json['routeName'] ?? '/',
|
||||
arguments: json['arguments'] ?? {},
|
||||
timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(),
|
||||
conversationId: json['conversationId'],
|
||||
tabIndex: json['tabIndex'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Service to manage navigation state preservation and restoration
|
||||
class NavigationStateService {
|
||||
static final NavigationStateService _instance =
|
||||
NavigationStateService._internal();
|
||||
factory NavigationStateService() => _instance;
|
||||
NavigationStateService._internal();
|
||||
|
||||
static const String _navigationStackKey = 'navigation_stack';
|
||||
static const String _currentStateKey = 'current_navigation_state';
|
||||
static const String _deepLinkStateKey = 'deep_link_state';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
final List<NavigationState> _navigationStack = [];
|
||||
NavigationState? _currentState;
|
||||
final ValueNotifier<NavigationState?> _stateNotifier = ValueNotifier(null);
|
||||
|
||||
/// Initialize the service
|
||||
Future<void> initialize() async {
|
||||
try {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
await _loadNavigationState();
|
||||
debugPrint('DEBUG: NavigationStateService initialized');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to initialize NavigationStateService: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current navigation state as a ValueNotifier for listening to changes
|
||||
ValueNotifier<NavigationState?> get stateNotifier => _stateNotifier;
|
||||
|
||||
/// Get current navigation state
|
||||
NavigationState? get currentState => _currentState;
|
||||
|
||||
/// Get navigation stack
|
||||
List<NavigationState> get navigationStack =>
|
||||
List.unmodifiable(_navigationStack);
|
||||
|
||||
/// Push a new navigation state
|
||||
Future<void> pushState({
|
||||
required String routeName,
|
||||
Map<String, dynamic> arguments = const {},
|
||||
String? conversationId,
|
||||
int? tabIndex,
|
||||
}) async {
|
||||
try {
|
||||
final state = NavigationState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
tabIndex: tabIndex,
|
||||
);
|
||||
|
||||
_navigationStack.add(state);
|
||||
_currentState = state;
|
||||
_stateNotifier.value = state;
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state pushed - ${state.routeName}');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to push navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the last navigation state
|
||||
Future<NavigationState?> popState() async {
|
||||
try {
|
||||
if (_navigationStack.isEmpty) return null;
|
||||
|
||||
final poppedState = _navigationStack.removeLast();
|
||||
_currentState = _navigationStack.isNotEmpty
|
||||
? _navigationStack.last
|
||||
: null;
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state popped - ${poppedState.routeName}');
|
||||
return poppedState;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to pop navigation state: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update current state with new information
|
||||
Future<void> updateCurrentState({
|
||||
String? conversationId,
|
||||
int? tabIndex,
|
||||
Map<String, dynamic>? additionalArgs,
|
||||
}) async {
|
||||
try {
|
||||
if (_currentState == null) return;
|
||||
|
||||
final updatedArgs = <String, dynamic>{
|
||||
..._currentState!.arguments,
|
||||
if (additionalArgs != null) ...additionalArgs,
|
||||
};
|
||||
|
||||
final updatedState = NavigationState(
|
||||
routeName: _currentState!.routeName,
|
||||
arguments: updatedArgs,
|
||||
conversationId: conversationId ?? _currentState!.conversationId,
|
||||
tabIndex: tabIndex ?? _currentState!.tabIndex,
|
||||
timestamp: _currentState!.timestamp,
|
||||
);
|
||||
|
||||
// Update both current state and last item in stack
|
||||
_currentState = updatedState;
|
||||
if (_navigationStack.isNotEmpty) {
|
||||
_navigationStack[_navigationStack.length - 1] = updatedState;
|
||||
}
|
||||
|
||||
_stateNotifier.value = updatedState;
|
||||
await _saveNavigationState();
|
||||
|
||||
debugPrint('DEBUG: Navigation state updated');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to update navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear navigation stack but preserve current state
|
||||
Future<void> clearStack() async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
if (_currentState != null) {
|
||||
_navigationStack.add(_currentState!);
|
||||
}
|
||||
await _saveNavigationState();
|
||||
debugPrint('DEBUG: Navigation stack cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear navigation stack: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace entire navigation stack
|
||||
Future<void> replaceStack(List<NavigationState> newStack) async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
_navigationStack.addAll(newStack);
|
||||
_currentState = newStack.isNotEmpty ? newStack.last : null;
|
||||
_stateNotifier.value = _currentState;
|
||||
|
||||
await _saveNavigationState();
|
||||
debugPrint(
|
||||
'DEBUG: Navigation stack replaced with ${newStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to replace navigation stack: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle deep link by preserving navigation context
|
||||
Future<void> handleDeepLink({
|
||||
required String routeName,
|
||||
Map<String, dynamic> arguments = const {},
|
||||
String? conversationId,
|
||||
bool preserveStack = true,
|
||||
}) async {
|
||||
try {
|
||||
// Save deep link state for restoration
|
||||
final deepLinkState = NavigationState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
);
|
||||
|
||||
await _saveDeepLinkState(deepLinkState);
|
||||
|
||||
if (preserveStack) {
|
||||
// Add to existing stack instead of replacing
|
||||
await pushState(
|
||||
routeName: routeName,
|
||||
arguments: arguments,
|
||||
conversationId: conversationId,
|
||||
);
|
||||
} else {
|
||||
// Replace stack with deep link
|
||||
await replaceStack([deepLinkState]);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Deep link handled - $routeName');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to handle deep link: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the conversation context from current navigation state
|
||||
String? getConversationContext() {
|
||||
return _currentState?.conversationId;
|
||||
}
|
||||
|
||||
/// Get the current tab index
|
||||
int? getCurrentTabIndex() {
|
||||
return _currentState?.tabIndex;
|
||||
}
|
||||
|
||||
/// Generate breadcrumb navigation based on current stack
|
||||
List<NavigationBreadcrumb> generateBreadcrumbs() {
|
||||
final breadcrumbs = <NavigationBreadcrumb>[];
|
||||
|
||||
for (int i = 0; i < _navigationStack.length; i++) {
|
||||
final state = _navigationStack[i];
|
||||
final isLast = i == _navigationStack.length - 1;
|
||||
|
||||
breadcrumbs.add(
|
||||
NavigationBreadcrumb(
|
||||
title: _getRouteTitle(state.routeName),
|
||||
routeName: state.routeName,
|
||||
arguments: state.arguments,
|
||||
isActive: isLast,
|
||||
canNavigateBack: i > 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
/// Check if we can navigate back
|
||||
bool canGoBack() {
|
||||
return _navigationStack.length > 1;
|
||||
}
|
||||
|
||||
/// Get previous state without popping
|
||||
NavigationState? getPreviousState() {
|
||||
if (_navigationStack.length < 2) return null;
|
||||
return _navigationStack[_navigationStack.length - 2];
|
||||
}
|
||||
|
||||
/// Restore navigation state on app startup
|
||||
Future<void> restoreNavigationState(NavigatorState navigator) async {
|
||||
try {
|
||||
await _loadNavigationState();
|
||||
|
||||
if (_currentState != null) {
|
||||
// Attempt to restore to the last known state
|
||||
debugPrint(
|
||||
'DEBUG: Restoring navigation to ${_currentState!.routeName}',
|
||||
);
|
||||
|
||||
// This would need to be implemented based on your routing setup
|
||||
// navigator.pushNamedAndRemoveUntil(
|
||||
// _currentState!.routeName,
|
||||
// (route) => false,
|
||||
// arguments: _currentState!.arguments,
|
||||
// );
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to restore navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all navigation state
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
_navigationStack.clear();
|
||||
_currentState = null;
|
||||
_stateNotifier.value = null;
|
||||
|
||||
await _prefs?.remove(_navigationStackKey);
|
||||
await _prefs?.remove(_currentStateKey);
|
||||
await _prefs?.remove(_deepLinkStateKey);
|
||||
|
||||
debugPrint('DEBUG: All navigation state cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save navigation state to persistent storage
|
||||
Future<void> _saveNavigationState() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
// Save navigation stack
|
||||
final stackJson = _navigationStack
|
||||
.map((state) => state.toJson())
|
||||
.toList();
|
||||
await _prefs!.setString(_navigationStackKey, jsonEncode(stackJson));
|
||||
|
||||
// Save current state
|
||||
if (_currentState != null) {
|
||||
await _prefs!.setString(
|
||||
_currentStateKey,
|
||||
jsonEncode(_currentState!.toJson()),
|
||||
);
|
||||
} else {
|
||||
await _prefs!.remove(_currentStateKey);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save navigation state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Load navigation state from persistent storage
|
||||
Future<void> _loadNavigationState() async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
// Load navigation stack
|
||||
final stackJsonString = _prefs!.getString(_navigationStackKey);
|
||||
if (stackJsonString != null) {
|
||||
final stackJson = jsonDecode(stackJsonString) as List;
|
||||
_navigationStack.clear();
|
||||
for (final stateJson in stackJson) {
|
||||
if (stateJson is Map<String, dynamic>) {
|
||||
_navigationStack.add(NavigationState.fromJson(stateJson));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load current state
|
||||
final currentStateJsonString = _prefs!.getString(_currentStateKey);
|
||||
if (currentStateJsonString != null) {
|
||||
final currentStateJson =
|
||||
jsonDecode(currentStateJsonString) as Map<String, dynamic>;
|
||||
_currentState = NavigationState.fromJson(currentStateJson);
|
||||
_stateNotifier.value = _currentState;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Navigation state loaded - ${_navigationStack.length} states',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to load navigation state: $e');
|
||||
// Clear corrupted state
|
||||
await clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
/// Save deep link state for restoration
|
||||
Future<void> _saveDeepLinkState(NavigationState state) async {
|
||||
if (_prefs == null) return;
|
||||
|
||||
try {
|
||||
await _prefs!.setString(_deepLinkStateKey, jsonEncode(state.toJson()));
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save deep link state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get user-friendly title for route name
|
||||
String _getRouteTitle(String routeName) {
|
||||
switch (routeName) {
|
||||
case '/':
|
||||
case '/home':
|
||||
return 'Home';
|
||||
case '/chat':
|
||||
return 'Chat';
|
||||
case '/settings':
|
||||
return 'Settings';
|
||||
case '/profile':
|
||||
return 'Profile';
|
||||
case '/conversations':
|
||||
return 'Conversations';
|
||||
default:
|
||||
// Convert route name to title case
|
||||
return routeName
|
||||
.replaceAll('/', '')
|
||||
.split('_')
|
||||
.map(
|
||||
(word) => word.isNotEmpty
|
||||
? '${word[0].toUpperCase()}${word.substring(1)}'
|
||||
: '',
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Breadcrumb navigation item
|
||||
class NavigationBreadcrumb {
|
||||
final String title;
|
||||
final String routeName;
|
||||
final Map<String, dynamic> arguments;
|
||||
final bool isActive;
|
||||
final bool canNavigateBack;
|
||||
|
||||
NavigationBreadcrumb({
|
||||
required this.title,
|
||||
required this.routeName,
|
||||
required this.arguments,
|
||||
required this.isActive,
|
||||
required this.canNavigateBack,
|
||||
});
|
||||
}
|
||||
375
lib/core/services/optimized_storage_service.dart
Normal file
375
lib/core/services/optimized_storage_service.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../models/conversation.dart';
|
||||
|
||||
/// Optimized storage service with single secure storage implementation
|
||||
/// Eliminates dual storage overhead and improves performance
|
||||
class OptimizedStorageService {
|
||||
final SharedPreferences _prefs;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
OptimizedStorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required SharedPreferences prefs,
|
||||
}) : _prefs = prefs,
|
||||
_secureCredentialStorage = SecureCredentialStorage();
|
||||
|
||||
// Optimized key names with versioning
|
||||
static const String _authTokenKey = 'auth_token_v3';
|
||||
static const String _activeServerIdKey = 'active_server_id';
|
||||
static const String _rememberCredentialsKey = 'remember_credentials';
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
static const String _localConversationsKey = 'local_conversations';
|
||||
static const String _onboardingSeenKey = 'onboarding_seen_v1';
|
||||
static const String _reviewerModeKey = 'reviewer_mode_v1';
|
||||
|
||||
// Cache for frequently accessed data
|
||||
final Map<String, dynamic> _cache = {};
|
||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
|
||||
/// Auth Token Management (Optimized with caching)
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
try {
|
||||
await _secureCredentialStorage.saveAuthToken(token);
|
||||
_cache[_authTokenKey] = token;
|
||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
||||
debugPrint('DEBUG: Auth token saved and cached');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save auth token: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAuthToken() async {
|
||||
// Check cache first
|
||||
if (_isCacheValid(_authTokenKey)) {
|
||||
final cachedToken = _cache[_authTokenKey] as String?;
|
||||
if (cachedToken != null) {
|
||||
debugPrint('DEBUG: Using cached auth token');
|
||||
return cachedToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final token = await _secureCredentialStorage.getAuthToken();
|
||||
if (token != null) {
|
||||
_cache[_authTokenKey] = token;
|
||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve auth token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteAuthToken() async {
|
||||
try {
|
||||
await _secureCredentialStorage.deleteAuthToken();
|
||||
_cache.remove(_authTokenKey);
|
||||
_cacheTimestamps.remove(_authTokenKey);
|
||||
debugPrint('DEBUG: Auth token deleted and cache cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete auth token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential Management (Single storage implementation)
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
await _secureCredentialStorage.saveCredentials(
|
||||
serverId: serverId,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Cache the fact that credentials exist (not the credentials themselves)
|
||||
_cache['has_credentials'] = true;
|
||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
||||
|
||||
debugPrint('DEBUG: Credentials saved via optimized storage');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save credentials: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
try {
|
||||
// Use single storage implementation - no fallback needed
|
||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||
|
||||
// Update cache flag
|
||||
_cache['has_credentials'] = credentials != null;
|
||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
||||
|
||||
return credentials;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve credentials: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
try {
|
||||
await _secureCredentialStorage.deleteSavedCredentials();
|
||||
_cache.remove('has_credentials');
|
||||
_cacheTimestamps.remove('has_credentials');
|
||||
debugPrint('DEBUG: Credentials deleted via optimized storage');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete credentials: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick check if credentials exist (uses cache)
|
||||
Future<bool> hasCredentials() async {
|
||||
if (_isCacheValid('has_credentials')) {
|
||||
return _cache['has_credentials'] == true;
|
||||
}
|
||||
|
||||
final credentials = await getSavedCredentials();
|
||||
return credentials != null;
|
||||
}
|
||||
|
||||
/// Remember Credentials Flag
|
||||
Future<void> setRememberCredentials(bool remember) async {
|
||||
await _prefs.setBool(_rememberCredentialsKey, remember);
|
||||
}
|
||||
|
||||
bool getRememberCredentials() {
|
||||
return _prefs.getBool(_rememberCredentialsKey) ?? false;
|
||||
}
|
||||
|
||||
/// Server Configuration (Optimized)
|
||||
Future<void> saveServerConfigs(List<ServerConfig> configs) async {
|
||||
try {
|
||||
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
|
||||
await _secureCredentialStorage.saveServerConfigs(jsonString);
|
||||
|
||||
// Cache config count for quick checks
|
||||
_cache['server_config_count'] = configs.length;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
|
||||
debugPrint('DEBUG: Server configs saved (${configs.length} configs)');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save server configs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ServerConfig>> getServerConfigs() async {
|
||||
try {
|
||||
final jsonString = await _secureCredentialStorage.getServerConfigs();
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_cache['server_config_count'] = 0;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
return [];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString) as List<dynamic>;
|
||||
final configs = decoded
|
||||
.map((item) => ServerConfig.fromJson(item))
|
||||
.toList();
|
||||
|
||||
// Update cache
|
||||
_cache['server_config_count'] = configs.length;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
|
||||
return configs;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve server configs: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Active Server Management
|
||||
Future<void> setActiveServerId(String? serverId) async {
|
||||
if (serverId != null) {
|
||||
await _prefs.setString(_activeServerIdKey, serverId);
|
||||
} else {
|
||||
await _prefs.remove(_activeServerIdKey);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
_cache[_activeServerIdKey] = serverId;
|
||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
||||
}
|
||||
|
||||
Future<String?> getActiveServerId() async {
|
||||
// Check cache first
|
||||
if (_isCacheValid(_activeServerIdKey)) {
|
||||
return _cache[_activeServerIdKey] as String?;
|
||||
}
|
||||
|
||||
final serverId = _prefs.getString(_activeServerIdKey);
|
||||
_cache[_activeServerIdKey] = serverId;
|
||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
||||
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/// Theme Management
|
||||
String? getThemeMode() {
|
||||
return _prefs.getString(_themeModeKey);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String mode) async {
|
||||
await _prefs.setString(_themeModeKey, mode);
|
||||
}
|
||||
|
||||
/// Onboarding
|
||||
Future<bool> getOnboardingSeen() async {
|
||||
return _prefs.getBool(_onboardingSeenKey) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setOnboardingSeen(bool seen) async {
|
||||
await _prefs.setBool(_onboardingSeenKey, seen);
|
||||
}
|
||||
|
||||
/// Reviewer mode (persisted)
|
||||
Future<bool> getReviewerMode() async {
|
||||
return _prefs.getBool(_reviewerModeKey) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setReviewerMode(bool enabled) async {
|
||||
await _prefs.setBool(_reviewerModeKey, enabled);
|
||||
}
|
||||
|
||||
/// Local Conversations (Optimized with compression)
|
||||
Future<List<Conversation>> getLocalConversations() async {
|
||||
try {
|
||||
final jsonString = _prefs.getString(_localConversationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(jsonString) as List<dynamic>;
|
||||
return decoded.map((item) => Conversation.fromJson(item)).toList();
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve local conversations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
// Only save essential data to reduce storage size
|
||||
final lightweightConversations = conversations
|
||||
.map(
|
||||
(conv) => {
|
||||
'id': conv.id,
|
||||
'title': conv.title,
|
||||
'updatedAt': conv.updatedAt.toIso8601String(),
|
||||
'messageCount': conv.messages.length,
|
||||
// Don't save full message content locally
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
|
||||
final jsonString = jsonEncode(lightweightConversations);
|
||||
await _prefs.setString(_localConversationsKey, jsonString);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Saved ${conversations.length} local conversations (lightweight)',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save local conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch Operations for Performance
|
||||
Future<void> clearAuthData() async {
|
||||
try {
|
||||
// Clear auth-related data in batch
|
||||
await Future.wait([
|
||||
deleteAuthToken(),
|
||||
deleteSavedCredentials(),
|
||||
_prefs.remove(_rememberCredentialsKey),
|
||||
_prefs.remove(_activeServerIdKey),
|
||||
]);
|
||||
|
||||
// Clear related cache entries
|
||||
_cache.removeWhere(
|
||||
(key, value) =>
|
||||
key.contains('auth') ||
|
||||
key.contains('credentials') ||
|
||||
key.contains('server'),
|
||||
);
|
||||
_cacheTimestamps.removeWhere(
|
||||
(key, value) =>
|
||||
key.contains('auth') ||
|
||||
key.contains('credentials') ||
|
||||
key.contains('server'),
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Auth data cleared in batch operation');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear auth data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await Future.wait([_secureCredentialStorage.clearAll(), _prefs.clear()]);
|
||||
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
|
||||
debugPrint('DEBUG: All storage cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear all storage: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage Health Check
|
||||
Future<bool> isSecureStorageAvailable() async {
|
||||
return await _secureCredentialStorage.isSecureStorageAvailable();
|
||||
}
|
||||
|
||||
/// Cache Management Utilities
|
||||
bool _isCacheValid(String key) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return false;
|
||||
|
||||
return DateTime.now().difference(timestamp) < _cacheTimeout;
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
debugPrint('DEBUG: Storage cache cleared');
|
||||
}
|
||||
|
||||
/// Migration from old storage service (one-time operation)
|
||||
Future<void> migrateFromLegacyStorage() async {
|
||||
try {
|
||||
debugPrint('DEBUG: Starting migration from legacy storage');
|
||||
|
||||
// This would be called once during app upgrade
|
||||
// Implementation would depend on the specific migration needs
|
||||
// For now, the SecureCredentialStorage already handles legacy migration
|
||||
|
||||
debugPrint('DEBUG: Legacy storage migration completed');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Legacy storage migration failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance Monitoring
|
||||
Map<String, dynamic> getStorageStats() {
|
||||
return {
|
||||
'cacheSize': _cache.length,
|
||||
'cachedKeys': _cache.keys.toList(),
|
||||
'lastAccess': _cacheTimestamps.entries
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
408
lib/core/services/platform_service.dart
Normal file
408
lib/core/services/platform_service.dart
Normal file
@@ -0,0 +1,408 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// Service for platform-specific features and polish
|
||||
class PlatformService {
|
||||
/// Check if running on iOS
|
||||
static bool get isIOS => Platform.isIOS;
|
||||
|
||||
/// Check if running on Android
|
||||
static bool get isAndroid => Platform.isAndroid;
|
||||
|
||||
/// Provide haptic feedback appropriate for the action
|
||||
static void hapticFeedback({HapticType type = HapticType.light}) {
|
||||
if (isIOS) {
|
||||
_iOSHapticFeedback(type);
|
||||
} else if (isAndroid) {
|
||||
_androidHapticFeedback(type);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provide haptic feedback respecting user preferences
|
||||
static void hapticFeedbackWithSettings({
|
||||
HapticType type = HapticType.light,
|
||||
required bool hapticEnabled,
|
||||
}) {
|
||||
if (hapticEnabled) {
|
||||
hapticFeedback(type: type);
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS-specific haptic feedback
|
||||
static void _iOSHapticFeedback(HapticType type) {
|
||||
switch (type) {
|
||||
case HapticType.light:
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticType.medium:
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case HapticType.heavy:
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
case HapticType.selection:
|
||||
HapticFeedback.selectionClick();
|
||||
break;
|
||||
case HapticType.success:
|
||||
// iOS has specific success haptics in newer versions
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticType.warning:
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case HapticType.error:
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Android-specific haptic feedback
|
||||
static void _androidHapticFeedback(HapticType type) {
|
||||
switch (type) {
|
||||
case HapticType.light:
|
||||
case HapticType.selection:
|
||||
HapticFeedback.lightImpact();
|
||||
break;
|
||||
case HapticType.medium:
|
||||
case HapticType.success:
|
||||
HapticFeedback.mediumImpact();
|
||||
break;
|
||||
case HapticType.heavy:
|
||||
case HapticType.warning:
|
||||
case HapticType.error:
|
||||
HapticFeedback.heavyImpact();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate button style
|
||||
static ButtonStyle getPlatformButtonStyle({
|
||||
Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
EdgeInsetsGeometry? padding,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
// Return Material button style for both platforms since ButtonStyle is a Material concept
|
||||
return ElevatedButton.styleFrom(
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
padding: padding,
|
||||
elevation: isDestructive ? 0 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get platform-appropriate card elevation
|
||||
static double getPlatformCardElevation({bool isRaised = false}) {
|
||||
if (isIOS) {
|
||||
return 0; // iOS prefers flat design
|
||||
} else {
|
||||
return isRaised ? 4.0 : 1.0; // Android Material elevation
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate border radius
|
||||
static BorderRadius getPlatformBorderRadius({double radius = 12}) {
|
||||
if (isIOS) {
|
||||
return BorderRadius.circular(
|
||||
radius + 2,
|
||||
); // iOS prefers slightly more rounded
|
||||
} else {
|
||||
return BorderRadius.circular(radius); // Android standard
|
||||
}
|
||||
}
|
||||
|
||||
/// Create platform-appropriate navigation transition
|
||||
static Route<T> createPlatformRoute<T>({
|
||||
required Widget page,
|
||||
RouteSettings? settings,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return CupertinoPageRoute<T>(
|
||||
builder: (context) => page,
|
||||
settings: settings,
|
||||
);
|
||||
} else {
|
||||
return MaterialPageRoute<T>(
|
||||
builder: (context) => page,
|
||||
settings: settings,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show platform-appropriate action sheet
|
||||
static Future<T?> showPlatformActionSheet<T>({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
List<PlatformActionSheetAction>? actions,
|
||||
PlatformActionSheetAction? cancelAction,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return showCupertinoModalPopup<T>(
|
||||
context: context,
|
||||
builder: (context) => CupertinoActionSheet(
|
||||
title: Text(title),
|
||||
actions: actions
|
||||
?.map(
|
||||
(action) => CupertinoActionSheetAction(
|
||||
onPressed: action.onPressed,
|
||||
isDestructiveAction: action.isDestructive,
|
||||
child: Text(action.title),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
cancelButton: cancelAction != null
|
||||
? CupertinoActionSheetAction(
|
||||
onPressed: cancelAction.onPressed,
|
||||
child: Text(cancelAction.title),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (context) => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||||
),
|
||||
...actions?.map(
|
||||
(action) => ListTile(
|
||||
title: Text(
|
||||
action.title,
|
||||
style: TextStyle(
|
||||
color: action.isDestructive
|
||||
? Theme.of(context).colorScheme.error
|
||||
: null,
|
||||
),
|
||||
),
|
||||
onTap: action.onPressed,
|
||||
),
|
||||
) ??
|
||||
[],
|
||||
if (cancelAction != null)
|
||||
ListTile(
|
||||
title: Text(cancelAction.title),
|
||||
onTap: cancelAction.onPressed,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show platform-appropriate alert dialog
|
||||
static Future<bool?> showPlatformAlert({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = 'OK',
|
||||
String? cancelText,
|
||||
bool isDestructive = false,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return showCupertinoDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(content),
|
||||
actions: [
|
||||
if (cancelText != null)
|
||||
CupertinoDialogAction(
|
||||
child: Text(cancelText),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: isDestructive,
|
||||
child: Text(confirmText),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: context.conduitTheme.surfaceBackground,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.dialog),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyle(color: context.conduitTheme.textPrimary),
|
||||
),
|
||||
content: Text(
|
||||
content,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
if (cancelText != null)
|
||||
TextButton(
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: TextStyle(color: context.conduitTheme.textSecondary),
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: isDestructive
|
||||
? context.conduitTheme.error
|
||||
: context.conduitTheme.buttonPrimary,
|
||||
),
|
||||
child: Text(confirmText),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate loading indicator
|
||||
static Widget getPlatformLoadingIndicator({double size = 20, Color? color}) {
|
||||
if (isIOS) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CupertinoActivityIndicator(color: color),
|
||||
);
|
||||
} else {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: color != null
|
||||
? AlwaysStoppedAnimation<Color>(color)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform-appropriate switch widget
|
||||
static Widget getPlatformSwitch({
|
||||
required bool value,
|
||||
required ValueChanged<bool>? onChanged,
|
||||
Color? activeColor,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return CupertinoSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeTrackColor: activeColor,
|
||||
);
|
||||
} else {
|
||||
return Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: activeColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply platform-specific status bar styling
|
||||
static void setPlatformStatusBarStyle({
|
||||
bool isDarkContent = false,
|
||||
Color? backgroundColor,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarBrightness: isDarkContent
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
statusBarIconBrightness: isDarkContent
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
statusBarColor: backgroundColor,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: backgroundColor ?? Colors.transparent,
|
||||
statusBarIconBrightness: isDarkContent
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
systemNavigationBarColor: backgroundColor,
|
||||
systemNavigationBarIconBrightness: isDarkContent
|
||||
? Brightness.dark
|
||||
: Brightness.light,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if device supports dynamic colors (Android 12+)
|
||||
static bool supportsDynamicColors() {
|
||||
// This would require platform channel implementation
|
||||
// For now, return false
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Get platform-appropriate text selection controls
|
||||
static TextSelectionControls getPlatformTextSelectionControls() {
|
||||
if (isIOS) {
|
||||
return cupertinoTextSelectionControls;
|
||||
} else {
|
||||
return materialTextSelectionControls;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create platform-specific app bar
|
||||
static PreferredSizeWidget createPlatformAppBar({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
bool centerTitle = false,
|
||||
Color? backgroundColor,
|
||||
Color? foregroundColor,
|
||||
}) {
|
||||
if (isIOS) {
|
||||
return CupertinoNavigationBar(
|
||||
middle: Text(title),
|
||||
trailing: actions != null && actions.isNotEmpty
|
||||
? Row(mainAxisSize: MainAxisSize.min, children: actions)
|
||||
: null,
|
||||
leading: leading,
|
||||
backgroundColor: backgroundColor,
|
||||
);
|
||||
} else {
|
||||
return AppBar(
|
||||
title: Text(title),
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
centerTitle: centerTitle,
|
||||
backgroundColor: backgroundColor,
|
||||
foregroundColor: foregroundColor,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Types of haptic feedback
|
||||
enum HapticType { light, medium, heavy, selection, success, warning, error }
|
||||
|
||||
/// Action sheet action configuration
|
||||
class PlatformActionSheetAction {
|
||||
final String title;
|
||||
final VoidCallback onPressed;
|
||||
final bool isDestructive;
|
||||
|
||||
const PlatformActionSheetAction({
|
||||
required this.title,
|
||||
required this.onPressed,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
}
|
||||
326
lib/core/services/secure_credential_storage.dart
Normal file
326
lib/core/services/secure_credential_storage.dart
Normal file
@@ -0,0 +1,326 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Enhanced secure credential storage with platform-specific optimizations
|
||||
class SecureCredentialStorage {
|
||||
late final FlutterSecureStorage _secureStorage;
|
||||
|
||||
SecureCredentialStorage() {
|
||||
_secureStorage = FlutterSecureStorage(
|
||||
aOptions: _getAndroidOptions(),
|
||||
iOptions: _getIOSOptions(),
|
||||
);
|
||||
}
|
||||
|
||||
static const String _credentialsKey = 'user_credentials_v2';
|
||||
static const String _serverConfigsKey = 'server_configs_v2';
|
||||
static const String _authTokenKey = 'auth_token_v2';
|
||||
|
||||
/// Get Android-specific secure storage options
|
||||
AndroidOptions _getAndroidOptions() {
|
||||
return const AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
sharedPreferencesName: 'conduit_secure_prefs',
|
||||
preferencesKeyPrefix: 'conduit_',
|
||||
resetOnError: true,
|
||||
// Use more compatible encryption algorithms
|
||||
keyCipherAlgorithm: KeyCipherAlgorithm.RSA_ECB_PKCS1Padding,
|
||||
storageCipherAlgorithm: StorageCipherAlgorithm.AES_CBC_PKCS7Padding,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get iOS-specific secure storage options
|
||||
IOSOptions _getIOSOptions() {
|
||||
return const IOSOptions(
|
||||
groupId: 'group.conduit.app',
|
||||
accountName: 'conduit_secure_storage',
|
||||
synchronizable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Save user credentials securely
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
// First check if secure storage is available
|
||||
final isAvailable = await isSecureStorageAvailable();
|
||||
if (!isAvailable) {
|
||||
throw Exception('Secure storage is not available on this device');
|
||||
}
|
||||
|
||||
final credentials = {
|
||||
'serverId': serverId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'savedAt': DateTime.now().toIso8601String(),
|
||||
'deviceId': await _getDeviceFingerprint(),
|
||||
'version': '2.0', // Version for migration purposes
|
||||
};
|
||||
|
||||
final encryptedData = await _encryptData(jsonEncode(credentials));
|
||||
await _secureStorage.write(key: _credentialsKey, value: encryptedData);
|
||||
|
||||
// Verify the save was successful by attempting to read it back
|
||||
final verifyData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (verifyData == null || verifyData.isEmpty) {
|
||||
throw Exception(
|
||||
'Failed to verify credential save - storage returned null',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Credentials saved and verified securely');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save credentials: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve saved credentials
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
try {
|
||||
final encryptedData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (encryptedData == null || encryptedData.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonString = await _decryptData(encryptedData);
|
||||
final decoded = jsonDecode(jsonString);
|
||||
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
debugPrint('Warning: Invalid credentials format');
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate device fingerprint for additional security, but be more lenient
|
||||
final savedDeviceId = decoded['deviceId']?.toString();
|
||||
if (savedDeviceId != null) {
|
||||
final currentDeviceId = await _getDeviceFingerprint();
|
||||
|
||||
if (savedDeviceId != currentDeviceId) {
|
||||
debugPrint(
|
||||
'Info: Device fingerprint changed, but allowing credential access for better UX',
|
||||
);
|
||||
// Don't clear credentials immediately - allow the user to continue
|
||||
// They can re-login if needed, which will update the fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!decoded.containsKey('serverId') ||
|
||||
!decoded.containsKey('username') ||
|
||||
!decoded.containsKey('password')) {
|
||||
debugPrint(
|
||||
'Warning: Invalid saved credentials format - missing required fields',
|
||||
);
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if credentials are too old (optional expiration)
|
||||
final savedAt = decoded['savedAt']?.toString();
|
||||
if (savedAt != null) {
|
||||
try {
|
||||
final savedTime = DateTime.parse(savedAt);
|
||||
final now = DateTime.now();
|
||||
final daysSinceCreated = now.difference(savedTime).inDays;
|
||||
|
||||
// Warn if credentials are very old (but don't delete them)
|
||||
if (daysSinceCreated > 90) {
|
||||
debugPrint(
|
||||
'Info: Saved credentials are $daysSinceCreated days old',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Could not parse savedAt timestamp: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'serverId': decoded['serverId']?.toString() ?? '',
|
||||
'username': decoded['username']?.toString() ?? '',
|
||||
'password': decoded['password']?.toString() ?? '',
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
};
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve credentials: $e');
|
||||
// Don't delete credentials on retrieval errors - they might be recoverable
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete saved credentials
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
debugPrint('DEBUG: Credentials deleted');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete credentials: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save auth token securely
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
try {
|
||||
final encryptedToken = await _encryptData(token);
|
||||
await _secureStorage.write(key: _authTokenKey, value: encryptedToken);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save auth token: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get auth token
|
||||
Future<String?> getAuthToken() async {
|
||||
try {
|
||||
final encryptedToken = await _secureStorage.read(key: _authTokenKey);
|
||||
if (encryptedToken == null) return null;
|
||||
|
||||
return await _decryptData(encryptedToken);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve auth token: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete auth token
|
||||
Future<void> deleteAuthToken() async {
|
||||
try {
|
||||
await _secureStorage.delete(key: _authTokenKey);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to delete auth token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Save server configurations securely
|
||||
Future<void> saveServerConfigs(String configsJson) async {
|
||||
try {
|
||||
final encryptedConfigs = await _encryptData(configsJson);
|
||||
await _secureStorage.write(
|
||||
key: _serverConfigsKey,
|
||||
value: encryptedConfigs,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to save server configs: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get server configurations
|
||||
Future<String?> getServerConfigs() async {
|
||||
try {
|
||||
final encryptedConfigs = await _secureStorage.read(
|
||||
key: _serverConfigsKey,
|
||||
);
|
||||
if (encryptedConfigs == null) return null;
|
||||
|
||||
return await _decryptData(encryptedConfigs);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to retrieve server configs: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if secure storage is available
|
||||
Future<bool> isSecureStorageAvailable() async {
|
||||
try {
|
||||
// Test write and read
|
||||
const testKey = 'test_availability';
|
||||
const testValue = 'test';
|
||||
|
||||
await _secureStorage.write(key: testKey, value: testValue);
|
||||
final result = await _secureStorage.read(key: testKey);
|
||||
await _secureStorage.delete(key: testKey);
|
||||
|
||||
return result == testValue;
|
||||
} catch (e) {
|
||||
debugPrint('WARNING: Secure storage not available: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all secure data
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await _secureStorage.deleteAll();
|
||||
debugPrint('DEBUG: All secure data cleared');
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to clear secure data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt data using additional layer of encryption
|
||||
Future<String> _encryptData(String data) async {
|
||||
try {
|
||||
// For now, return the data as-is since FlutterSecureStorage already provides encryption
|
||||
// In a more advanced implementation, you could add an additional layer of AES encryption
|
||||
return data;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to encrypt data: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt data
|
||||
Future<String> _decryptData(String encryptedData) async {
|
||||
try {
|
||||
// For now, return the data as-is since FlutterSecureStorage handles decryption
|
||||
// This matches the encryption method above
|
||||
return encryptedData;
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to decrypt data: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a device fingerprint for additional security
|
||||
Future<String> _getDeviceFingerprint() async {
|
||||
try {
|
||||
// Create a more stable device fingerprint
|
||||
final platformInfo = {
|
||||
'platform': Platform.operatingSystem,
|
||||
// Use only major version to avoid fingerprint changes on minor updates
|
||||
'majorVersion': Platform.operatingSystemVersion.split('.').first,
|
||||
'isPhysicalDevice': true, // In a real implementation, you'd detect this
|
||||
// Add a static component to ensure consistency
|
||||
'appId': 'conduit_app_v1',
|
||||
};
|
||||
|
||||
final fingerprintData = jsonEncode(platformInfo);
|
||||
final bytes = utf8.encode(fingerprintData);
|
||||
final digest = sha256.convert(bytes);
|
||||
|
||||
return digest.toString();
|
||||
} catch (e) {
|
||||
debugPrint('WARNING: Failed to generate device fingerprint: $e');
|
||||
// Return a consistent fallback fingerprint
|
||||
return 'stable_fallback_device_id';
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate from old storage format if needed
|
||||
Future<void> migrateFromOldStorage(
|
||||
Map<String, String>? oldCredentials,
|
||||
) async {
|
||||
if (oldCredentials == null) return;
|
||||
|
||||
try {
|
||||
await saveCredentials(
|
||||
serverId: oldCredentials['serverId'] ?? '',
|
||||
username: oldCredentials['username'] ?? '',
|
||||
password: oldCredentials['password'] ?? '',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully migrated credentials to new secure format',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('ERROR: Failed to migrate credentials: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
275
lib/core/services/settings_service.dart
Normal file
275
lib/core/services/settings_service.dart
Normal file
@@ -0,0 +1,275 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'animation_service.dart';
|
||||
|
||||
/// Service for managing app-wide settings including accessibility preferences
|
||||
class SettingsService {
|
||||
static const String _reduceMotionKey = 'reduce_motion';
|
||||
static const String _animationSpeedKey = 'animation_speed';
|
||||
static const String _hapticFeedbackKey = 'haptic_feedback';
|
||||
static const String _highContrastKey = 'high_contrast';
|
||||
static const String _largeTextKey = 'large_text';
|
||||
static const String _darkModeKey = 'dark_mode';
|
||||
|
||||
/// Get reduced motion preference
|
||||
static Future<bool> getReduceMotion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_reduceMotionKey) ?? false;
|
||||
}
|
||||
|
||||
/// Set reduced motion preference
|
||||
static Future<void> setReduceMotion(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_reduceMotionKey, value);
|
||||
}
|
||||
|
||||
/// Get animation speed multiplier (0.5 - 2.0)
|
||||
static Future<double> getAnimationSpeed() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getDouble(_animationSpeedKey) ?? 1.0;
|
||||
}
|
||||
|
||||
/// Set animation speed multiplier
|
||||
static Future<void> setAnimationSpeed(double value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_animationSpeedKey, value.clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
/// Get haptic feedback preference
|
||||
static Future<bool> getHapticFeedback() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hapticFeedbackKey) ?? true;
|
||||
}
|
||||
|
||||
/// Set haptic feedback preference
|
||||
static Future<void> setHapticFeedback(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hapticFeedbackKey, value);
|
||||
}
|
||||
|
||||
/// Get high contrast preference
|
||||
static Future<bool> getHighContrast() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_highContrastKey) ?? false;
|
||||
}
|
||||
|
||||
/// Set high contrast preference
|
||||
static Future<void> setHighContrast(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_highContrastKey, value);
|
||||
}
|
||||
|
||||
/// Get large text preference
|
||||
static Future<bool> getLargeText() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_largeTextKey) ?? false;
|
||||
}
|
||||
|
||||
/// Set large text preference
|
||||
static Future<void> setLargeText(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_largeTextKey, value);
|
||||
}
|
||||
|
||||
/// Get dark mode preference
|
||||
static Future<bool> getDarkMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_darkModeKey) ?? true; // Default to dark
|
||||
}
|
||||
|
||||
/// Set dark mode preference
|
||||
static Future<void> setDarkMode(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_darkModeKey, value);
|
||||
}
|
||||
|
||||
/// Load all settings
|
||||
static Future<AppSettings> loadSettings() async {
|
||||
return AppSettings(
|
||||
reduceMotion: await getReduceMotion(),
|
||||
animationSpeed: await getAnimationSpeed(),
|
||||
hapticFeedback: await getHapticFeedback(),
|
||||
highContrast: await getHighContrast(),
|
||||
largeText: await getLargeText(),
|
||||
darkMode: await getDarkMode(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save all settings
|
||||
static Future<void> saveSettings(AppSettings settings) async {
|
||||
await Future.wait([
|
||||
setReduceMotion(settings.reduceMotion),
|
||||
setAnimationSpeed(settings.animationSpeed),
|
||||
setHapticFeedback(settings.hapticFeedback),
|
||||
setHighContrast(settings.highContrast),
|
||||
setLargeText(settings.largeText),
|
||||
setDarkMode(settings.darkMode),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Get effective animation duration considering all settings
|
||||
static Duration getEffectiveAnimationDuration(
|
||||
BuildContext context,
|
||||
Duration defaultDuration,
|
||||
AppSettings settings,
|
||||
) {
|
||||
// Check system reduced motion first
|
||||
if (MediaQuery.of(context).disableAnimations || settings.reduceMotion) {
|
||||
return Duration.zero;
|
||||
}
|
||||
|
||||
// Apply user animation speed preference
|
||||
final adjustedMs =
|
||||
(defaultDuration.inMilliseconds / settings.animationSpeed).round();
|
||||
return Duration(milliseconds: adjustedMs.clamp(50, 1000));
|
||||
}
|
||||
|
||||
/// Get text scale factor considering user preferences
|
||||
static double getEffectiveTextScaleFactor(
|
||||
BuildContext context,
|
||||
AppSettings settings,
|
||||
) {
|
||||
final textScaler = MediaQuery.of(context).textScaler;
|
||||
double baseScale = textScaler.scale(1.0);
|
||||
|
||||
// Apply large text preference
|
||||
if (settings.largeText) {
|
||||
baseScale *= 1.3;
|
||||
}
|
||||
|
||||
// Ensure reasonable bounds
|
||||
return baseScale.clamp(0.8, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data class for app settings
|
||||
class AppSettings {
|
||||
final bool reduceMotion;
|
||||
final double animationSpeed;
|
||||
final bool hapticFeedback;
|
||||
final bool highContrast;
|
||||
final bool largeText;
|
||||
final bool darkMode;
|
||||
|
||||
const AppSettings({
|
||||
this.reduceMotion = false,
|
||||
this.animationSpeed = 1.0,
|
||||
this.hapticFeedback = true,
|
||||
this.highContrast = false,
|
||||
this.largeText = false,
|
||||
this.darkMode = true,
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
bool? reduceMotion,
|
||||
double? animationSpeed,
|
||||
bool? hapticFeedback,
|
||||
bool? highContrast,
|
||||
bool? largeText,
|
||||
bool? darkMode,
|
||||
}) {
|
||||
return AppSettings(
|
||||
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||
animationSpeed: animationSpeed ?? this.animationSpeed,
|
||||
hapticFeedback: hapticFeedback ?? this.hapticFeedback,
|
||||
highContrast: highContrast ?? this.highContrast,
|
||||
largeText: largeText ?? this.largeText,
|
||||
darkMode: darkMode ?? this.darkMode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is AppSettings &&
|
||||
other.reduceMotion == reduceMotion &&
|
||||
other.animationSpeed == animationSpeed &&
|
||||
other.hapticFeedback == hapticFeedback &&
|
||||
other.highContrast == highContrast &&
|
||||
other.largeText == largeText &&
|
||||
other.darkMode == darkMode;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
reduceMotion,
|
||||
animationSpeed,
|
||||
hapticFeedback,
|
||||
highContrast,
|
||||
largeText,
|
||||
darkMode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for app settings
|
||||
final appSettingsProvider =
|
||||
StateNotifierProvider<AppSettingsNotifier, AppSettings>(
|
||||
(ref) => AppSettingsNotifier(),
|
||||
);
|
||||
|
||||
class AppSettingsNotifier extends StateNotifier<AppSettings> {
|
||||
AppSettingsNotifier() : super(const AppSettings()) {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final settings = await SettingsService.loadSettings();
|
||||
state = settings;
|
||||
}
|
||||
|
||||
Future<void> setReduceMotion(bool value) async {
|
||||
state = state.copyWith(reduceMotion: value);
|
||||
await SettingsService.setReduceMotion(value);
|
||||
}
|
||||
|
||||
Future<void> setAnimationSpeed(double value) async {
|
||||
state = state.copyWith(animationSpeed: value);
|
||||
await SettingsService.setAnimationSpeed(value);
|
||||
}
|
||||
|
||||
Future<void> setHapticFeedback(bool value) async {
|
||||
state = state.copyWith(hapticFeedback: value);
|
||||
await SettingsService.setHapticFeedback(value);
|
||||
}
|
||||
|
||||
Future<void> setHighContrast(bool value) async {
|
||||
state = state.copyWith(highContrast: value);
|
||||
await SettingsService.setHighContrast(value);
|
||||
}
|
||||
|
||||
Future<void> setLargeText(bool value) async {
|
||||
state = state.copyWith(largeText: value);
|
||||
await SettingsService.setLargeText(value);
|
||||
}
|
||||
|
||||
Future<void> setDarkMode(bool value) async {
|
||||
state = state.copyWith(darkMode: value);
|
||||
await SettingsService.setDarkMode(value);
|
||||
}
|
||||
|
||||
Future<void> resetToDefaults() async {
|
||||
const defaultSettings = AppSettings();
|
||||
await SettingsService.saveSettings(defaultSettings);
|
||||
state = defaultSettings;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for checking if haptic feedback should be enabled
|
||||
final hapticEnabledProvider = Provider<bool>((ref) {
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
return settings.hapticFeedback;
|
||||
});
|
||||
|
||||
/// Provider for effective animation settings
|
||||
final effectiveAnimationSettingsProvider = Provider<AnimationSettings>((ref) {
|
||||
final appSettings = ref.watch(appSettingsProvider);
|
||||
|
||||
return AnimationSettings(
|
||||
reduceMotion: appSettings.reduceMotion,
|
||||
performance: AnimationPerformance.adaptive,
|
||||
animationSpeed: appSettings.animationSpeed,
|
||||
);
|
||||
});
|
||||
372
lib/core/services/storage_service.dart
Normal file
372
lib/core/services/storage_service.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../models/conversation.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
|
||||
class StorageService {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final SharedPreferences _prefs;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
StorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required SharedPreferences prefs,
|
||||
}) : _secureStorage = secureStorage,
|
||||
_prefs = prefs,
|
||||
_secureCredentialStorage = SecureCredentialStorage();
|
||||
|
||||
// Secure storage keys
|
||||
static const String _authTokenKey = 'auth_token';
|
||||
static const String _serverConfigsKey = 'server_configs';
|
||||
static const String _activeServerIdKey = 'active_server_id';
|
||||
static const String _credentialsKey = 'saved_credentials';
|
||||
static const String _rememberCredentialsKey = 'remember_credentials';
|
||||
|
||||
// Shared preferences keys
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
static const String _localConversationsKey = 'local_conversations';
|
||||
|
||||
// Auth token management - using enhanced secure storage
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
await _secureCredentialStorage.saveAuthToken(token);
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
await _secureStorage.write(key: _authTokenKey, value: token);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAuthToken() async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
final token = await _secureCredentialStorage.getAuthToken();
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
}
|
||||
|
||||
// Fallback to legacy storage
|
||||
return await _secureStorage.read(key: _authTokenKey);
|
||||
}
|
||||
|
||||
Future<void> deleteAuthToken() async {
|
||||
// Clear from both storages to ensure complete cleanup
|
||||
try {
|
||||
await _secureCredentialStorage.deleteAuthToken();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to delete from enhanced storage: $e');
|
||||
}
|
||||
|
||||
await _secureStorage.delete(key: _authTokenKey);
|
||||
}
|
||||
|
||||
// Credential management for auto-login - using enhanced secure storage
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
// Check if enhanced secure storage is available
|
||||
final isSecureAvailable = await _secureCredentialStorage
|
||||
.isSecureStorageAvailable();
|
||||
if (!isSecureAvailable) {
|
||||
debugPrint(
|
||||
'DEBUG: Enhanced secure storage not available, using legacy storage',
|
||||
);
|
||||
throw Exception('Enhanced secure storage not available');
|
||||
}
|
||||
|
||||
await _secureCredentialStorage.saveCredentials(
|
||||
serverId: serverId,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
debugPrint('DEBUG: Credentials saved using enhanced secure storage');
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
|
||||
// Fallback to legacy storage
|
||||
try {
|
||||
final credentials = {
|
||||
'serverId': serverId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'savedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
await _secureStorage.write(
|
||||
key: _credentialsKey,
|
||||
value: jsonEncode(credentials),
|
||||
);
|
||||
|
||||
// Verify the fallback save
|
||||
final verifyData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (verifyData == null || verifyData.isEmpty) {
|
||||
throw Exception(
|
||||
'Failed to save credentials even with fallback storage',
|
||||
);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Credentials saved using fallback storage');
|
||||
} catch (fallbackError) {
|
||||
debugPrint(
|
||||
'ERROR: Both enhanced and fallback credential storage failed: $fallbackError',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
// Try enhanced secure storage first
|
||||
try {
|
||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||
if (credentials != null) {
|
||||
return credentials;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Enhanced secure storage failed, using fallback: $e');
|
||||
}
|
||||
|
||||
// Fallback to legacy storage and migrate if found
|
||||
try {
|
||||
final jsonString = await _secureStorage.read(key: _credentialsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return null;
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
// Validate that credentials have required fields
|
||||
if (!decoded.containsKey('serverId') ||
|
||||
!decoded.containsKey('username') ||
|
||||
!decoded.containsKey('password')) {
|
||||
debugPrint('Warning: Invalid saved credentials format');
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
final legacyCredentials = {
|
||||
'serverId': decoded['serverId']?.toString() ?? '',
|
||||
'username': decoded['username']?.toString() ?? '',
|
||||
'password': decoded['password']?.toString() ?? '',
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
};
|
||||
|
||||
// Attempt to migrate to enhanced storage
|
||||
try {
|
||||
await _secureCredentialStorage.migrateFromOldStorage(legacyCredentials);
|
||||
// If migration successful, clean up legacy storage
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully migrated credentials to enhanced storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to migrate credentials: $e');
|
||||
}
|
||||
|
||||
return legacyCredentials;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading saved credentials: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
// Clear from both storages to ensure complete cleanup
|
||||
try {
|
||||
await _secureCredentialStorage.deleteSavedCredentials();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to delete from enhanced storage: $e');
|
||||
}
|
||||
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
await setRememberCredentials(false);
|
||||
}
|
||||
|
||||
// Remember credentials preference
|
||||
Future<void> setRememberCredentials(bool remember) async {
|
||||
await _prefs.setBool(_rememberCredentialsKey, remember);
|
||||
}
|
||||
|
||||
bool getRememberCredentials() {
|
||||
return _prefs.getBool(_rememberCredentialsKey) ?? false;
|
||||
}
|
||||
|
||||
// Server configuration management
|
||||
Future<void> saveServerConfigs(List<ServerConfig> configs) async {
|
||||
final json = configs.map((c) => c.toJson()).toList();
|
||||
await _secureStorage.write(key: _serverConfigsKey, value: jsonEncode(json));
|
||||
}
|
||||
|
||||
Future<List<ServerConfig>> getServerConfigs() async {
|
||||
try {
|
||||
final jsonString = await _secureStorage.read(key: _serverConfigsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
debugPrint('Warning: Server configs data is not a list, resetting');
|
||||
return [];
|
||||
}
|
||||
|
||||
final configs = <ServerConfig>[];
|
||||
for (final item in decoded) {
|
||||
try {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// Validate required fields before parsing
|
||||
if (item.containsKey('id') &&
|
||||
item.containsKey('name') &&
|
||||
item.containsKey('url')) {
|
||||
configs.add(ServerConfig.fromJson(item));
|
||||
} else {
|
||||
debugPrint(
|
||||
'Warning: Skipping invalid server config: missing required fields',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to parse server config: $e');
|
||||
// Continue with other configs
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (e) {
|
||||
debugPrint('Error loading server configs: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setActiveServerId(String? serverId) async {
|
||||
if (serverId == null) {
|
||||
await _secureStorage.delete(key: _activeServerIdKey);
|
||||
} else {
|
||||
await _secureStorage.write(key: _activeServerIdKey, value: serverId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getActiveServerId() async {
|
||||
return await _secureStorage.read(key: _activeServerIdKey);
|
||||
}
|
||||
|
||||
// Theme management
|
||||
String? getThemeMode() {
|
||||
return _prefs.getString(_themeModeKey);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String mode) async {
|
||||
await _prefs.setString(_themeModeKey, mode);
|
||||
}
|
||||
|
||||
// Local conversation management
|
||||
Future<List<Conversation>> getLocalConversations() async {
|
||||
final jsonString = _prefs.getString(_localConversationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
debugPrint(
|
||||
'Warning: Local conversations data is not a list, resetting',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
final conversations = <Conversation>[];
|
||||
for (final item in decoded) {
|
||||
try {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// Validate required fields before parsing
|
||||
if (item.containsKey('id') &&
|
||||
item.containsKey('title') &&
|
||||
item.containsKey('createdAt') &&
|
||||
item.containsKey('updatedAt')) {
|
||||
conversations.add(Conversation.fromJson(item));
|
||||
} else {
|
||||
debugPrint(
|
||||
'Warning: Skipping invalid conversation: missing required fields',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to parse conversation: $e');
|
||||
// Continue with other conversations
|
||||
}
|
||||
}
|
||||
|
||||
return conversations;
|
||||
} catch (e) {
|
||||
debugPrint('Error parsing local conversations: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
final json = conversations.map((c) => c.toJson()).toList();
|
||||
await _prefs.setString(_localConversationsKey, jsonEncode(json));
|
||||
} catch (e) {
|
||||
debugPrint('Error saving local conversations: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLocalConversation(Conversation conversation) async {
|
||||
final conversations = await getLocalConversations();
|
||||
conversations.add(conversation);
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
|
||||
Future<void> updateLocalConversation(Conversation conversation) async {
|
||||
final conversations = await getLocalConversations();
|
||||
final index = conversations.indexWhere((c) => c.id == conversation.id);
|
||||
if (index != -1) {
|
||||
conversations[index] = conversation;
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteLocalConversation(String conversationId) async {
|
||||
final conversations = await getLocalConversations();
|
||||
conversations.removeWhere((c) => c.id == conversationId);
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
Future<void> clearAll() async {
|
||||
// Clear enhanced secure storage
|
||||
try {
|
||||
await _secureCredentialStorage.clearAll();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to clear enhanced storage: $e');
|
||||
}
|
||||
|
||||
// Clear legacy storage
|
||||
await _secureStorage.deleteAll();
|
||||
await _prefs.clear();
|
||||
|
||||
debugPrint('DEBUG: All storage cleared');
|
||||
}
|
||||
|
||||
// Clear only auth-related data (keeping server configs and other settings)
|
||||
Future<void> clearAuthData() async {
|
||||
await deleteAuthToken();
|
||||
await deleteSavedCredentials();
|
||||
debugPrint('DEBUG: Auth data cleared');
|
||||
}
|
||||
|
||||
/// Check if enhanced secure storage is available
|
||||
Future<bool> isEnhancedSecureStorageAvailable() async {
|
||||
try {
|
||||
return await _secureCredentialStorage.isSecureStorageAvailable();
|
||||
} catch (e) {
|
||||
debugPrint('Warning: Failed to check enhanced storage availability: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
563
lib/core/services/user_friendly_error_handler.dart
Normal file
563
lib/core/services/user_friendly_error_handler.dart
Normal file
@@ -0,0 +1,563 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// User-friendly error messages and recovery actions
|
||||
class UserFriendlyErrorHandler {
|
||||
static final UserFriendlyErrorHandler _instance =
|
||||
UserFriendlyErrorHandler._internal();
|
||||
factory UserFriendlyErrorHandler() => _instance;
|
||||
UserFriendlyErrorHandler._internal();
|
||||
|
||||
/// Convert technical errors to user-friendly messages
|
||||
String getUserMessage(dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
|
||||
if (_isNetworkError(errorString)) {
|
||||
return _getNetworkErrorMessage(errorString);
|
||||
} else if (_isValidationError(errorString)) {
|
||||
return _getValidationErrorMessage(errorString);
|
||||
} else if (_isServerError(errorString)) {
|
||||
return _getServerErrorMessage(errorString);
|
||||
} else if (_isAuthenticationError(errorString)) {
|
||||
return _getAuthenticationErrorMessage(errorString);
|
||||
} else if (_isFileError(errorString)) {
|
||||
return _getFileErrorMessage(errorString);
|
||||
} else if (_isPermissionError(errorString)) {
|
||||
return _getPermissionErrorMessage(errorString);
|
||||
}
|
||||
|
||||
// Log technical details for debugging
|
||||
_logError(error);
|
||||
|
||||
// Return generic user-friendly message
|
||||
return 'Something unexpected happened. Please try again.';
|
||||
}
|
||||
|
||||
/// Get recovery actions for the error
|
||||
List<ErrorRecoveryAction> getRecoveryActions(dynamic error) {
|
||||
final errorString = error.toString().toLowerCase();
|
||||
|
||||
if (_isNetworkError(errorString)) {
|
||||
return _getNetworkRecoveryActions();
|
||||
} else if (_isServerError(errorString)) {
|
||||
return _getServerRecoveryActions();
|
||||
} else if (_isAuthenticationError(errorString)) {
|
||||
return _getAuthRecoveryActions();
|
||||
} else if (_isFileError(errorString)) {
|
||||
return _getFileRecoveryActions();
|
||||
} else if (_isPermissionError(errorString)) {
|
||||
return _getPermissionRecoveryActions();
|
||||
}
|
||||
|
||||
return _getGenericRecoveryActions();
|
||||
}
|
||||
|
||||
/// Build error widget with recovery options
|
||||
Widget buildErrorWidget(
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
VoidCallback? onDismiss,
|
||||
bool showDetails = false,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final actions = getRecoveryActions(error);
|
||||
|
||||
return ErrorCard(
|
||||
message: message,
|
||||
actions: actions,
|
||||
onRetry: onRetry,
|
||||
onDismiss: onDismiss,
|
||||
showDetails: showDetails,
|
||||
technicalDetails: showDetails ? error.toString() : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error dialog with recovery options
|
||||
Future<void> showErrorDialog(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
bool showDetails = false,
|
||||
}) async {
|
||||
final message = getUserMessage(error);
|
||||
final actions = getRecoveryActions(error);
|
||||
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => ErrorDialog(
|
||||
message: message,
|
||||
actions: actions,
|
||||
onRetry: onRetry,
|
||||
showDetails: showDetails,
|
||||
technicalDetails: showDetails ? error.toString() : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show error snackbar with quick action
|
||||
void showErrorSnackbar(
|
||||
BuildContext context,
|
||||
dynamic error, {
|
||||
VoidCallback? onRetry,
|
||||
}) {
|
||||
final message = getUserMessage(error);
|
||||
final actions = getRecoveryActions(error);
|
||||
final primaryAction = actions.isNotEmpty ? actions.first : null;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: context.conduitTheme.error,
|
||||
action: primaryAction != null && onRetry != null
|
||||
? SnackBarAction(
|
||||
label: primaryAction.label,
|
||||
onPressed: onRetry,
|
||||
textColor: context.conduitTheme.textInverse,
|
||||
)
|
||||
: null,
|
||||
duration: const Duration(seconds: 4),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Network error detection and handling
|
||||
bool _isNetworkError(String error) {
|
||||
return error.contains('socketexception') ||
|
||||
error.contains('network') ||
|
||||
error.contains('connection') ||
|
||||
error.contains('timeout') ||
|
||||
error.contains('handshake') ||
|
||||
error.contains('no address associated');
|
||||
}
|
||||
|
||||
String _getNetworkErrorMessage(String error) {
|
||||
if (error.contains('timeout')) {
|
||||
return 'Connection timed out. Please check your internet connection and try again.';
|
||||
} else if (error.contains('no address associated')) {
|
||||
return 'Cannot reach the server. Please check your server URL and internet connection.';
|
||||
} else if (error.contains('connection refused')) {
|
||||
return 'Server is not responding. Please verify the server is running and accessible.';
|
||||
}
|
||||
return 'Network connection problem. Please check your internet connection.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getNetworkRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Retry',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Try the request again',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Check Connection',
|
||||
action: ErrorActionType.checkConnection,
|
||||
description: 'Verify your internet connection',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Server error detection and handling
|
||||
bool _isServerError(String error) {
|
||||
return error.contains('500') ||
|
||||
error.contains('502') ||
|
||||
error.contains('503') ||
|
||||
error.contains('504') ||
|
||||
error.contains('server error') ||
|
||||
error.contains('internal server error');
|
||||
}
|
||||
|
||||
String _getServerErrorMessage(String error) {
|
||||
if (error.contains('500')) {
|
||||
return 'Server is experiencing issues. This is usually temporary.';
|
||||
} else if (error.contains('502') || error.contains('503')) {
|
||||
return 'Server is temporarily unavailable. Please try again in a moment.';
|
||||
} else if (error.contains('504')) {
|
||||
return 'Server took too long to respond. Please try again.';
|
||||
}
|
||||
return 'Server is having problems. Please try again later.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getServerRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry your request',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Wait & Retry',
|
||||
action: ErrorActionType.retryLater,
|
||||
description: 'Wait a moment then try again',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Authentication error detection and handling
|
||||
bool _isAuthenticationError(String error) {
|
||||
return error.contains('401') ||
|
||||
error.contains('403') ||
|
||||
error.contains('unauthorized') ||
|
||||
error.contains('forbidden') ||
|
||||
error.contains('authentication') ||
|
||||
error.contains('token');
|
||||
}
|
||||
|
||||
String _getAuthenticationErrorMessage(String error) {
|
||||
if (error.contains('401') || error.contains('unauthorized')) {
|
||||
return 'Your session has expired. Please sign in again.';
|
||||
} else if (error.contains('403') || error.contains('forbidden')) {
|
||||
return 'You don\'t have permission to perform this action.';
|
||||
} else if (error.contains('token')) {
|
||||
return 'Authentication token is invalid. Please sign in again.';
|
||||
}
|
||||
return 'Authentication problem. Please sign in again.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getAuthRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Sign In',
|
||||
action: ErrorActionType.signIn,
|
||||
description: 'Sign in to your account',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry the request',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Validation error detection and handling
|
||||
bool _isValidationError(String error) {
|
||||
return error.contains('validation') ||
|
||||
error.contains('invalid') ||
|
||||
error.contains('format') ||
|
||||
error.contains('required') ||
|
||||
error.contains('400');
|
||||
}
|
||||
|
||||
String _getValidationErrorMessage(String error) {
|
||||
if (error.contains('email')) {
|
||||
return 'Please enter a valid email address.';
|
||||
} else if (error.contains('password')) {
|
||||
return 'Password doesn\'t meet requirements. Please check and try again.';
|
||||
} else if (error.contains('required')) {
|
||||
return 'Please fill in all required fields.';
|
||||
} else if (error.contains('format')) {
|
||||
return 'Some information is in the wrong format. Please check and try again.';
|
||||
}
|
||||
return 'Please check your input and try again.';
|
||||
}
|
||||
|
||||
// File error detection and handling
|
||||
bool _isFileError(String error) {
|
||||
return error.contains('file') ||
|
||||
error.contains('path') ||
|
||||
error.contains('directory') ||
|
||||
error.contains('not found') ||
|
||||
error.contains('access denied');
|
||||
}
|
||||
|
||||
String _getFileErrorMessage(String error) {
|
||||
if (error.contains('not found')) {
|
||||
return 'File not found. It may have been moved or deleted.';
|
||||
} else if (error.contains('access denied')) {
|
||||
return 'Cannot access the file. Please check permissions.';
|
||||
} else if (error.contains('too large')) {
|
||||
return 'File is too large. Please choose a smaller file.';
|
||||
}
|
||||
return 'Problem with the file. Please try a different file.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getFileRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Choose Different File',
|
||||
action: ErrorActionType.chooseFile,
|
||||
description: 'Select another file',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry the operation',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Permission error detection and handling
|
||||
bool _isPermissionError(String error) {
|
||||
return error.contains('permission') ||
|
||||
error.contains('denied') ||
|
||||
error.contains('unauthorized') ||
|
||||
error.contains('access');
|
||||
}
|
||||
|
||||
String _getPermissionErrorMessage(String error) {
|
||||
if (error.contains('camera')) {
|
||||
return 'Camera permission is required. Please enable it in settings.';
|
||||
} else if (error.contains('storage')) {
|
||||
return 'Storage permission is required. Please enable it in settings.';
|
||||
} else if (error.contains('microphone')) {
|
||||
return 'Microphone permission is required. Please enable it in settings.';
|
||||
}
|
||||
return 'Permission required. Please check app permissions in settings.';
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getPermissionRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Open Settings',
|
||||
action: ErrorActionType.openSettings,
|
||||
description: 'Open app settings to grant permissions',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry after granting permission',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<ErrorRecoveryAction> _getGenericRecoveryActions() {
|
||||
return [
|
||||
ErrorRecoveryAction(
|
||||
label: 'Try Again',
|
||||
action: ErrorActionType.retry,
|
||||
description: 'Retry the operation',
|
||||
),
|
||||
ErrorRecoveryAction(
|
||||
label: 'Go Back',
|
||||
action: ErrorActionType.goBack,
|
||||
description: 'Return to previous screen',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/// Log technical error details for debugging
|
||||
void _logError(dynamic error) {
|
||||
if (kDebugMode) {
|
||||
debugPrint('ERROR: $error');
|
||||
if (error is Error) {
|
||||
debugPrint('STACK TRACE: ${error.stackTrace}');
|
||||
}
|
||||
}
|
||||
|
||||
// In production, you might want to send this to a crash reporting service
|
||||
// FirebaseCrashlytics.instance.recordError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
/// Error recovery action definition
|
||||
class ErrorRecoveryAction {
|
||||
final String label;
|
||||
final ErrorActionType action;
|
||||
final String description;
|
||||
final VoidCallback? customAction;
|
||||
|
||||
ErrorRecoveryAction({
|
||||
required this.label,
|
||||
required this.action,
|
||||
required this.description,
|
||||
this.customAction,
|
||||
});
|
||||
}
|
||||
|
||||
/// Types of error recovery actions
|
||||
enum ErrorActionType {
|
||||
retry,
|
||||
retryLater,
|
||||
goBack,
|
||||
signIn,
|
||||
openSettings,
|
||||
checkConnection,
|
||||
chooseFile,
|
||||
contactSupport,
|
||||
dismiss,
|
||||
}
|
||||
|
||||
/// Error card widget
|
||||
class ErrorCard extends StatelessWidget {
|
||||
final String message;
|
||||
final List<ErrorRecoveryAction> actions;
|
||||
final VoidCallback? onRetry;
|
||||
final VoidCallback? onDismiss;
|
||||
final bool showDetails;
|
||||
final String? technicalDetails;
|
||||
|
||||
const ErrorCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.actions,
|
||||
this.onRetry,
|
||||
this.onDismiss,
|
||||
this.showDetails = false,
|
||||
this.technicalDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(Spacing.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
size: IconSize.lg,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm + Spacing.xs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: actions.take(2).map((action) {
|
||||
return ElevatedButton(
|
||||
onPressed: () => _handleAction(context, action),
|
||||
child: Text(action.label),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
if (showDetails && technicalDetails != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
ExpansionTile(
|
||||
title: const Text('Technical Details'),
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: SelectableText(
|
||||
technicalDetails!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleAction(BuildContext context, ErrorRecoveryAction action) {
|
||||
if (action.customAction != null) {
|
||||
action.customAction!();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action.action) {
|
||||
case ErrorActionType.retry:
|
||||
onRetry?.call();
|
||||
break;
|
||||
case ErrorActionType.goBack:
|
||||
Navigator.of(context).pop();
|
||||
break;
|
||||
case ErrorActionType.dismiss:
|
||||
onDismiss?.call();
|
||||
break;
|
||||
case ErrorActionType.signIn:
|
||||
// Navigate to sign in page
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
break;
|
||||
case ErrorActionType.openSettings:
|
||||
// Open app settings - would need platform-specific implementation
|
||||
break;
|
||||
default:
|
||||
onRetry?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error dialog widget
|
||||
class ErrorDialog extends StatelessWidget {
|
||||
final String message;
|
||||
final List<ErrorRecoveryAction> actions;
|
||||
final VoidCallback? onRetry;
|
||||
final bool showDetails;
|
||||
final String? technicalDetails;
|
||||
|
||||
const ErrorDialog({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.actions,
|
||||
this.onRetry,
|
||||
this.showDetails = false,
|
||||
this.technicalDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(width: Spacing.sm + Spacing.xs),
|
||||
const Text('Error'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(message),
|
||||
if (showDetails && technicalDetails != null) ...[
|
||||
const SizedBox(height: Spacing.md),
|
||||
ExpansionTile(
|
||||
title: const Text('Technical Details'),
|
||||
children: [
|
||||
SelectableText(
|
||||
technicalDetails!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: AppTypography.labelMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
if (actions.isNotEmpty)
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
if (actions.first.action == ErrorActionType.retry) {
|
||||
onRetry?.call();
|
||||
}
|
||||
},
|
||||
child: Text(actions.first.label),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user