chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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
});

View 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());

View 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;
},
);
}
}

View 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,
],
],
),
),
);
},
);
}
}

View 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);
};
}
}

View 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,
),
),
),
),
],
),
);
}
}

View 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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#x27;')
.replaceAll('/', '&#x2F;');
}
/// 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(),
),
);
}
}

View 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';
}

View 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,
});
}

View 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(),
};
}
}

View 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,
});
}

View 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');
}
}
}

View 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,
);
});

View 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;
}
}
}

View 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),
),
],
);
}
}