refactor: app customization

This commit is contained in:
cogwheel0
2025-09-07 11:29:29 +05:30
parent a16fb86e27
commit 0116a5be7b
12 changed files with 524 additions and 247 deletions

View File

@@ -43,13 +43,11 @@ class EnhancedImageAttachment extends ConsumerStatefulWidget {
class _EnhancedImageAttachmentState
extends ConsumerState<EnhancedImageAttachment>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
with AutomaticKeepAliveClientMixin {
String? _cachedImageData;
bool _isLoading = true;
String? _errorMessage;
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
bool _hasShownContent = false;
// Removed unused animation and state flags
@override
bool get wantKeepAlive => true;
@@ -57,14 +55,6 @@ class _EnhancedImageAttachmentState
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeInOut,
);
// Defer loading until after first frame to avoid accessing inherited widgets
// (e.g., Localizations) during initState
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -75,7 +65,6 @@ class _EnhancedImageAttachmentState
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@@ -87,11 +76,7 @@ class _EnhancedImageAttachmentState
setState(() {
_cachedImageData = _globalImageCache[widget.attachmentId];
_isLoading = false;
_hasShownContent = true;
});
if (!widget.disableAnimation) {
_animationController.forward();
}
}
return;
}
@@ -119,11 +104,7 @@ class _EnhancedImageAttachmentState
setState(() {
_cachedImageData = widget.attachmentId;
_isLoading = false;
_hasShownContent = true;
});
if (!widget.disableAnimation) {
_animationController.forward();
}
}
return;
}
@@ -140,11 +121,7 @@ class _EnhancedImageAttachmentState
setState(() {
_cachedImageData = fullUrl;
_isLoading = false;
_hasShownContent = true;
});
if (!widget.disableAnimation) {
_animationController.forward();
}
}
return;
} else {
@@ -214,11 +191,7 @@ class _EnhancedImageAttachmentState
setState(() {
_cachedImageData = fileContent;
_isLoading = false;
_hasShownContent = true;
});
if (!widget.disableAnimation) {
_animationController.forward();
}
}
} catch (e) {
final error = l10n.failedToLoadImage(e.toString());

View File

@@ -8,6 +8,8 @@ import '../../../core/services/settings_service.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/utils/ui_utils.dart';
import '../../../core/providers/app_providers.dart';
import '../../../l10n/app_localizations.dart';
class AppCustomizationPage extends ConsumerWidget {
const AppCustomizationPage({super.key});
@@ -15,6 +17,13 @@ class AppCustomizationPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsProvider);
final themeMode = ref.watch(themeModeProvider);
final platformBrightness = MediaQuery.platformBrightnessOf(context);
final bool isDarkEffective =
themeMode == ThemeMode.dark ||
(themeMode == ThemeMode.system &&
platformBrightness == Brightness.dark);
final locale = ref.watch(localeProvider);
return Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground,
@@ -30,10 +39,10 @@ class AppCustomizationPage extends ConsumerWidget {
color: context.conduitTheme.textPrimary,
),
onPressed: () => Navigator.of(context).maybePop(),
tooltip: 'Back',
tooltip: AppLocalizations.of(context)!.back,
),
title: Text(
'App Customization',
AppLocalizations.of(context)!.appCustomization,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
@@ -47,7 +56,7 @@ class AppCustomizationPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Display',
AppLocalizations.of(context)!.display,
style: context.conduitTheme.headingSmall?.copyWith(
color: context.conduitTheme.textPrimary,
),
@@ -57,6 +66,150 @@ class AppCustomizationPage extends ConsumerWidget {
padding: EdgeInsets.zero,
child: Column(
children: [
// Dark mode toggle
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary
.withValues(alpha: Alpha.highlight),
borderRadius:
BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.moon_stars,
android: Icons.dark_mode,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
AppLocalizations.of(context)!.darkMode,
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
themeMode == ThemeMode.system
? AppLocalizations.of(context)!.followingSystem(
platformBrightness == Brightness.dark
? AppLocalizations.of(context)!.themeDark
: AppLocalizations.of(context)!.themeLight,
)
: (isDarkEffective
? AppLocalizations.of(context)!
.currentlyUsingDarkTheme
: AppLocalizations.of(context)!
.currentlyUsingLightTheme),
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
trailing: Switch.adaptive(
value: isDarkEffective,
onChanged: (value) {
ref
.read(themeModeProvider.notifier)
.setTheme(value ? ThemeMode.dark : ThemeMode.light);
},
),
onTap: () {
final newValue = !isDarkEffective;
ref
.read(themeModeProvider.notifier)
.setTheme(
newValue ? ThemeMode.dark : ThemeMode.light);
},
),
Divider(color: context.conduitTheme.dividerColor, height: 1),
// App language selector
Builder(builder: (context) {
final currentCode = locale?.languageCode ?? 'system';
final label = () {
switch (currentCode) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case 'fr':
return 'Français';
case 'it':
return 'Italiano';
default:
return 'System';
}
}();
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary
.withValues(alpha: Alpha.highlight),
borderRadius:
BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.globe,
android: Icons.language,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
AppLocalizations.of(context)!.appLanguage,
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
label,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
trailing: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.chevron_right,
android: Icons.chevron_right,
),
color: context.conduitTheme.iconSecondary,
size: IconSize.small,
),
onTap: () async {
final selected = await _showLanguageSelector(
context, currentCode);
if (selected != null) {
if (selected == 'system') {
await ref
.read(localeProvider.notifier)
.setLocale(null);
} else {
await ref
.read(localeProvider.notifier)
.setLocale(Locale(selected));
}
}
},
);
}),
Divider(color: context.conduitTheme.dividerColor, height: 1),
SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
@@ -65,14 +218,15 @@ class AppCustomizationPage extends ConsumerWidget {
// Use platform defaults for switch colors to match theme
value: settings.omitProviderInModelName,
title: Text(
'Hide provider in model names',
AppLocalizations.of(context)!.hideProviderInModelNames,
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
'Show names like "gpt-4o" instead of "openai/gpt-4o".',
AppLocalizations.of(context)!
.hideProviderInModelNamesDescription,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
@@ -105,7 +259,7 @@ class AppCustomizationPage extends ConsumerWidget {
const SizedBox(height: Spacing.lg),
Text(
'Realtime',
AppLocalizations.of(context)!.realtime,
style: context.conduitTheme.headingSmall?.copyWith(
color: context.conduitTheme.textPrimary,
),
@@ -138,14 +292,14 @@ class AppCustomizationPage extends ConsumerWidget {
),
),
title: Text(
'Transport mode',
AppLocalizations.of(context)!.transportMode,
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
'Choose how the app connects for realtime updates.',
AppLocalizations.of(context)!.transportModeDescription,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
@@ -166,21 +320,23 @@ class AppCustomizationPage extends ConsumerWidget {
.read(appSettingsProvider.notifier)
.setSocketTransportMode(v);
},
items: const [
items: [
DropdownMenuItem(
value: 'auto',
child: Text('Auto (Polling + WebSocket)'),
child: Text(AppLocalizations.of(context)!
.transportModeAuto),
),
DropdownMenuItem(
value: 'ws',
child: Text('WebSocket only'),
child: Text(AppLocalizations.of(context)!
.transportModeWs),
),
],
decoration: const InputDecoration(
labelText: 'Mode',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.mode,
border: const OutlineInputBorder(),
isDense: true,
contentPadding: EdgeInsets.symmetric(
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
@@ -196,8 +352,10 @@ class AppCustomizationPage extends ConsumerWidget {
),
child: Text(
settings.socketTransportMode == 'auto'
? 'More robust on restrictive networks. Upgrades to WebSocket when possible.'
: 'Lower overhead, but may fail behind strict proxies/firewalls.',
? AppLocalizations.of(context)!
.transportModeAutoInfo
: AppLocalizations.of(context)!
.transportModeWsInfo,
style: context.conduitTheme.caption?.copyWith(
color: context.conduitTheme.textSecondary,
),
@@ -211,4 +369,56 @@ class AppCustomizationPage extends ConsumerWidget {
),
);
}
Future<String?> _showLanguageSelector(BuildContext context, String current) {
return showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.modal),
),
boxShadow: ConduitShadows.modal,
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: Spacing.sm),
ListTile(
title: Text(AppLocalizations.of(context)!.system),
trailing: current == 'system' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'system'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.english),
trailing: current == 'en' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'en'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.deutsch),
trailing: current == 'de' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'de'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.francais),
trailing: current == 'fr' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'fr'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.italiano),
trailing: current == 'it' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'it'),
),
const SizedBox(height: Spacing.sm),
],
),
),
),
);
}
}

View File

@@ -229,17 +229,13 @@ class ProfilePage extends ConsumerWidget {
children: [
_buildDefaultModelTile(context, ref),
Divider(color: context.conduitTheme.dividerColor, height: 1),
_buildThemeToggleTile(context, ref),
Divider(color: context.conduitTheme.dividerColor, height: 1),
_buildLanguageTile(context, ref),
Divider(color: context.conduitTheme.dividerColor, height: 1),
_buildAccountOption(
icon: UiUtils.platformIcon(
ios: CupertinoIcons.slider_horizontal_3,
android: Icons.tune,
),
title: 'App Customization',
subtitle: 'Personalize how names and UI display',
title: AppLocalizations.of(context)!.appCustomization,
subtitle: AppLocalizations.of(context)!.appCustomizationSubtitle,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
@@ -463,199 +459,7 @@ class ProfilePage extends ConsumerWidget {
);
}
Widget _buildLanguageTile(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeProvider);
final currentCode = locale?.languageCode ?? 'system';
final label = () {
switch (currentCode) {
case 'en':
return 'English';
case 'de':
return 'Deutsch';
case 'fr':
return 'Français';
case 'it':
return 'Italiano';
default:
return 'System';
}
}();
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.globe,
android: Icons.language,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
AppLocalizations.of(context)!.appLanguage,
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
label,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
trailing: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.chevron_right,
android: Icons.chevron_right,
),
color: context.conduitTheme.iconSecondary,
size: IconSize.small,
),
onTap: () async {
final selected = await _showLanguageSelector(context, currentCode);
if (selected != null) {
if (selected == 'system') {
await ref.read(localeProvider.notifier).setLocale(null);
} else {
await ref.read(localeProvider.notifier).setLocale(Locale(selected));
}
}
},
);
}
Future<String?> _showLanguageSelector(BuildContext context, String current) {
return showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => Container(
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.modal),
),
boxShadow: ConduitShadows.modal,
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: Spacing.sm),
ListTile(
title: Text(AppLocalizations.of(context)!.system),
trailing: current == 'system' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'system'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.english),
trailing: current == 'en' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'en'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.deutsch),
trailing: current == 'de' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'de'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.francais),
trailing: current == 'fr' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'fr'),
),
ListTile(
title: Text(AppLocalizations.of(context)!.italiano),
trailing: current == 'it' ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, 'it'),
),
const SizedBox(height: Spacing.sm),
],
),
),
),
);
}
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeModeProvider);
final platformBrightness = MediaQuery.platformBrightnessOf(context);
final bool isDarkEffective =
themeMode == ThemeMode.dark ||
(themeMode == ThemeMode.system &&
platformBrightness == Brightness.dark);
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: Alpha.highlight,
),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.moon_stars,
android: Icons.dark_mode,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
AppLocalizations.of(context)!.darkMode,
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
themeMode == ThemeMode.system
? AppLocalizations.of(context)!.followingSystem(
platformBrightness == Brightness.dark
? AppLocalizations.of(context)!.themeDark
: AppLocalizations.of(context)!.themeLight,
)
: (isDarkEffective
? AppLocalizations.of(context)!.currentlyUsingDarkTheme
: AppLocalizations.of(context)!.currentlyUsingLightTheme),
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
trailing: Switch.adaptive(
value: isDarkEffective,
onChanged: (value) {
ref
.read(themeModeProvider.notifier)
.setTheme(value ? ThemeMode.dark : ThemeMode.light);
},
),
onTap: () {
final newValue = !isDarkEffective;
ref
.read(themeModeProvider.notifier)
.setTheme(newValue ? ThemeMode.dark : ThemeMode.light);
},
);
}
// Theme and language controls moved to AppCustomizationPage.
Widget _buildAboutTile(BuildContext context) {
return _buildAccountOption(