💄 Better dashboard customization

This commit is contained in:
2026-01-17 15:51:34 +08:00
parent 6a0f351114
commit 6840054a41
4 changed files with 327 additions and 177 deletions

View File

@@ -1598,5 +1598,33 @@
"sidebar": "Sidebar", "sidebar": "Sidebar",
"dropFilesHere": "Drop your files here", "dropFilesHere": "Drop your files here",
"dragAndDropToAttach": "Drag your files here to attach it", "dragAndDropToAttach": "Drag your files here to attach it",
"customize": "Customize" "customize": "Customize",
"dashboardCustomizeTitle": "Customize Dashboard",
"dashboardTabVertical": "Vertical",
"dashboardTabHorizontal": "Horizontal",
"dashboardLayoutVertical": "Vertical Layout",
"dashboardLayoutHorizontal": "Horizontal Layout",
"dashboardAvailableCards": "Available Cards",
"dashboardDisplaySettings": "Display Settings",
"dashboardShowSearchBar": "Show Search Bar",
"dashboardShowClockAndCountdown": "Show Clock and Countdown",
"dashboardResetToDefaults": "Reset to Defaults",
"dashboardResetToDefaultsSubtitle": "Restore default dashboard layout and settings",
"dashboardResetConfirmMessage": "This will restore the dashboard to its default layout and settings. This action cannot be undone.",
"dashboardResetConfirmTitle": "Reset Dashboard",
"dashboardCardCheckIn": "Check In",
"dashboardCardFortuneGraph": "Fortune Graph",
"dashboardCardFortune": "Fortune",
"dashboardCardFeaturedPosts": "Featured Posts",
"dashboardCardFriends": "Friends",
"dashboardCardNotifications": "Notifications",
"dashboardCardChats": "Chats",
"dashboardCardActivityColumn": "Activity Column",
"dashboardCardPostsColumn": "Posts Column",
"dashboardCardSocialColumn": "Social Column",
"dashboardCardChatsColumn": "Chats Column",
"dashboardCardActivityColumnDescription": "Check In, Fortune Graph & Fortune",
"dashboardCardPostsColumnDescription": "Featured Posts",
"dashboardCardSocialColumnDescription": "Friends & Notifications",
"dashboardCardChatsColumnDescription": "Recent Chats"
} }

View File

@@ -363,6 +363,12 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
} }
state = state.copyWith(dashboardConfig: value); state = state.copyWith(dashboardConfig: value);
} }
void resetDashboardConfig() {
final prefs = ref.read(sharedPreferencesProvider);
prefs.remove(kAppDashboardConfig);
state = state.copyWith(dashboardConfig: null);
}
} }
final updateInfoProvider = final updateInfoProvider =

View File

@@ -144,8 +144,6 @@ class DashboardRenderer {
} }
} }
class DashboardGrid extends HookConsumerWidget { class DashboardGrid extends HookConsumerWidget {
const DashboardGrid({super.key}); const DashboardGrid({super.key});
@@ -273,7 +271,7 @@ class DashboardGrid extends HookConsumerWidget {
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
), ).tr(),
style: TextButton.styleFrom( style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -337,17 +335,15 @@ class _DashboardGridWide extends HookConsumerWidget {
} }
// Add configured columns in the specified order // Add configured columns in the specified order
final horizontalLayouts = appSettings.dashboardConfig?.horizontalLayouts ?? final horizontalLayouts =
appSettings.dashboardConfig?.horizontalLayouts ??
['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn']; ['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn'];
for (final columnId in horizontalLayouts) { for (final columnId in horizontalLayouts) {
children.add(DashboardRenderer.buildColumn(columnId, ref)); children.add(DashboardRenderer.buildColumn(columnId, ref));
} }
return Row( return Row(spacing: 16, children: children);
spacing: 16,
children: children,
);
} }
} }
@@ -367,17 +363,23 @@ class _DashboardGridNarrow extends HookConsumerWidget {
} }
// Add configured cards in the specified order // Add configured cards in the specified order
final verticalLayouts = appSettings.dashboardConfig?.verticalLayouts ?? final verticalLayouts =
['checkIn', 'fortuneCard', 'postFeatured', 'friendsOverview', 'notifications', 'chatList', 'fortuneGraph']; appSettings.dashboardConfig?.verticalLayouts ??
[
'checkIn',
'fortuneCard',
'postFeatured',
'friendsOverview',
'notifications',
'chatList',
'fortuneGraph',
];
for (final cardId in verticalLayouts) { for (final cardId in verticalLayouts) {
children.add(DashboardRenderer.buildCard(cardId, ref)); children.add(DashboardRenderer.buildCard(cardId, ref));
} }
return Column( return Column(spacing: 16, children: children);
spacing: 16,
children: children,
);
} }
} }

View File

@@ -1,28 +1,69 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/widgets/alert.dart';
import 'package:styled_widget/styled_widget.dart';
class DashboardCustomizationSheet extends HookConsumerWidget { class DashboardCustomizationSheet extends HookConsumerWidget {
const DashboardCustomizationSheet({super.key}); const DashboardCustomizationSheet({super.key});
static const Map<String, Map<String, dynamic>> _cardMetadata = { static Map<String, Map<String, dynamic>> _getCardMetadata(
// Vertical layout cards BuildContext context,
'checkIn': {'name': 'Check In', 'icon': Symbols.check_circle}, ) {
'fortuneGraph': {'name': 'Fortune Graph', 'icon': Symbols.show_chart}, return {
'fortuneCard': {'name': 'Fortune', 'icon': Symbols.lightbulb}, // Vertical layout cards
'postFeatured': {'name': 'Featured Posts', 'icon': Symbols.article}, 'checkIn': {
'friendsOverview': {'name': 'Friends', 'icon': Symbols.group}, 'name': 'dashboardCardCheckIn'.tr(),
'notifications': {'name': 'Notifications', 'icon': Symbols.notifications}, 'icon': Symbols.check_circle,
'chatList': {'name': 'Chats', 'icon': Symbols.chat}, },
// Horizontal layout columns 'fortuneGraph': {
'activityColumn': {'name': 'Activity Column', 'icon': Symbols.dashboard, 'description': 'Check In, Fortune Graph & Fortune'}, 'name': 'dashboardCardFortuneGraph'.tr(),
'postsColumn': {'name': 'Posts Column', 'icon': Symbols.article, 'description': 'Featured Posts'}, 'icon': Symbols.show_chart,
'socialColumn': {'name': 'Social Column', 'icon': Symbols.group, 'description': 'Friends & Notifications'}, },
'chatsColumn': {'name': 'Chats Column', 'icon': Symbols.chat, 'description': 'Recent Chats'}, 'fortuneCard': {
}; 'name': 'dashboardCardFortune'.tr(),
'icon': Symbols.lightbulb,
},
'postFeatured': {
'name': 'dashboardCardFeaturedPosts'.tr(),
'icon': Symbols.article,
},
'friendsOverview': {
'name': 'dashboardCardFriends'.tr(),
'icon': Symbols.group,
},
'notifications': {
'name': 'dashboardCardNotifications'.tr(),
'icon': Symbols.notifications,
},
'chatList': {'name': 'dashboardCardChats'.tr(), 'icon': Symbols.chat},
// Horizontal layout columns
'activityColumn': {
'name': 'dashboardCardActivityColumn'.tr(),
'icon': Symbols.dashboard,
'description': 'dashboardCardActivityColumnDescription'.tr(),
},
'postsColumn': {
'name': 'dashboardCardPostsColumn'.tr(),
'icon': Symbols.article,
'description': 'dashboardCardPostsColumnDescription'.tr(),
},
'socialColumn': {
'name': 'dashboardCardSocialColumn'.tr(),
'icon': Symbols.group,
'description': 'dashboardCardSocialColumnDescription'.tr(),
},
'chatsColumn': {
'name': 'dashboardCardChatsColumn'.tr(),
'icon': Symbols.chat,
'description': 'dashboardCardChatsColumnDescription'.tr(),
},
};
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -31,15 +72,18 @@ class DashboardCustomizationSheet extends HookConsumerWidget {
// Local state for editing // Local state for editing
final verticalLayouts = useState<List<String>>( final verticalLayouts = useState<List<String>>(
(appSettings.dashboardConfig?.verticalLayouts ?? [ (appSettings.dashboardConfig?.verticalLayouts ??
'checkIn', [
'fortuneCard', 'checkIn',
'postFeatured', 'fortuneCard',
'friendsOverview', 'postFeatured',
'notifications', 'friendsOverview',
'chatList', 'notifications',
'fortuneGraph', 'chatList',
]).where((id) => id != 'accountUnactivated').toList(), 'fortuneGraph',
])
.where((id) => id != 'accountUnactivated')
.toList(),
); );
final horizontalLayouts = useState<List<String>>( final horizontalLayouts = useState<List<String>>(
@@ -67,69 +111,54 @@ class DashboardCustomizationSheet extends HookConsumerWidget {
} }
return SheetScaffold( return SheetScaffold(
titleText: 'Customize Dashboard', titleText: 'dashboardCustomizeTitle'.tr(),
actions: [TextButton(onPressed: saveConfig, child: const Text('Save'))], actions: [IconButton(onPressed: saveConfig, icon: Icon(Symbols.save))],
child: Column( child: DefaultTabController(
children: [ length: 2,
TabBar( child: Column(
controller: tabController, children: [
tabs: const [ TabBar(
Tab(text: 'Vertical'),
Tab(text: 'Horizontal'),
],
),
Expanded(
child: TabBarView(
controller: tabController, controller: tabController,
children: [ tabs: [
// Vertical layout Tab(text: 'dashboardTabVertical'.tr()),
_buildLayoutEditor(context, 'Vertical Layout', verticalLayouts, false), Tab(text: 'dashboardTabHorizontal'.tr()),
// Horizontal layout
_buildLayoutEditor(
context,
'Horizontal Layout',
horizontalLayouts,
true,
),
], ],
), ),
), Expanded(
const Divider(), child: CustomScrollView(
// Settings checkboxes slivers: [
Padding( SliverFillRemaining(
padding: const EdgeInsets.all(16), child: TabBarView(
child: Column( controller: tabController,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ // Vertical layout
Text( _buildSliverLayoutEditor(
'Display Settings', context,
style: Theme.of(context).textTheme.titleMedium?.copyWith( ref,
fontWeight: FontWeight.bold, 'dashboardLayoutVertical'.tr(),
verticalLayouts,
false,
showSearchBar,
showClockAndCountdown,
),
// Horizontal layout
_buildSliverLayoutEditor(
context,
ref,
'dashboardLayoutHorizontal'.tr(),
horizontalLayouts,
true,
showSearchBar,
showClockAndCountdown,
),
],
),
), ),
), ],
const SizedBox(height: 16), ),
CheckboxListTile(
title: const Text('Show Search Bar'),
value: showSearchBar.value,
onChanged: (value) {
if (value != null) {
showSearchBar.value = value;
}
},
),
CheckboxListTile(
title: const Text('Show Clock and Countdown'),
value: showClockAndCountdown.value,
onChanged: (value) {
if (value != null) {
showClockAndCountdown.value = value;
}
},
),
],
), ),
), ],
], ),
), ),
); );
} }
@@ -150,78 +179,80 @@ class DashboardCustomizationSheet extends HookConsumerWidget {
return ['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn']; return ['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn'];
} }
Widget _buildLayoutEditor( Widget _buildSliverLayoutEditor(
BuildContext context, BuildContext context,
WidgetRef ref,
String title, String title,
ValueNotifier<List<String>> layouts, ValueNotifier<List<String>> layouts,
bool isHorizontal, bool isHorizontal,
ValueNotifier<bool> showSearchBar,
ValueNotifier<bool> showClockAndCountdown,
) { ) {
final cardMetadata = _getCardMetadata(context);
// Filter available cards based on layout mode // Filter available cards based on layout mode
final relevantCards = isHorizontal final relevantCards = isHorizontal
? _cardMetadata.entries.where((entry) => entry.key.contains('Column')).map((e) => e.key).toList() ? cardMetadata.entries
: _cardMetadata.entries.where((entry) => !entry.key.contains('Column')).map((e) => e.key).toList(); .where((entry) => entry.key.contains('Column'))
.map((e) => e.key)
.toList()
: cardMetadata.entries
.where((entry) => !entry.key.contains('Column'))
.map((e) => e.key)
.toList();
final availableCards = final availableCards = relevantCards
relevantCards.where((cardId) => !layouts.value.contains(cardId)).toList(); .where((cardId) => !layouts.value.contains(cardId))
.toList();
return Column( return CustomScrollView(
crossAxisAlignment: CrossAxisAlignment.start, slivers: [
children: [ // Title
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.all(16),
child: Text( child: Text(
title, title,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ).padding(horizontal: 24, top: 16, bottom: 8),
), ),
// Reorderable list for cards // Reorderable list for cards
Expanded( SliverReorderableList(
child: ReorderableListView.builder( itemCount: layouts.value.length,
buildDefaultDragHandles: false, itemBuilder: (context, index) {
itemCount: layouts.value.length, final cardId = layouts.value[index];
onReorder: (oldIndex, newIndex) { final metadata =
if (oldIndex < newIndex) { cardMetadata[cardId] ?? {'name': cardId, 'icon': Symbols.help};
newIndex -= 1;
}
final item = layouts.value.removeAt(oldIndex);
layouts.value.insert(newIndex, item);
// Trigger rebuild
layouts.value = List.from(layouts.value);
},
itemBuilder: (context, index) {
final cardId = layouts.value[index];
final metadata =
_cardMetadata[cardId] ??
{'name': cardId, 'icon': Symbols.help};
return Card( return ReorderableDragStartListener(
key: ValueKey(cardId), key: ValueKey(cardId),
index: index,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile( child: ListTile(
dense: true,
leading: Icon( leading: Icon(
metadata['icon'] as IconData, metadata['icon'] as IconData,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
contentPadding: const EdgeInsets.fromLTRB(16, 0, 8, 0),
title: Text(metadata['name'] as String), title: Text(metadata['name'] as String),
subtitle: isHorizontal && metadata.containsKey('description') subtitle: isHorizontal && metadata.containsKey('description')
? Text( ? Text(
metadata['description'] as String, metadata['description'] as String,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: Theme.of(context).colorScheme.onSurfaceVariant, ?.copyWith(
), color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
) )
: null, : null,
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ReorderableDragStartListener( Icon(
index: index, Symbols.drag_handle,
child: Icon( color: Theme.of(context).colorScheme.onSurfaceVariant,
Symbols.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
@@ -230,54 +261,137 @@ class DashboardCustomizationSheet extends HookConsumerWidget {
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
), ),
onPressed: () { onPressed: () {
layouts.value = layouts.value.where((id) => id != cardId).toList(); layouts.value = layouts.value
.where((id) => id != cardId)
.toList();
}, },
), ),
], ],
), ),
), ),
); ),
}, );
), },
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
newIndex -= 1;
}
final item = layouts.value.removeAt(oldIndex);
layouts.value.insert(newIndex, item);
layouts.value = List.from(layouts.value);
},
), ),
// Available cards to add back // Available cards to add back
if (availableCards.isNotEmpty) if (availableCards.isNotEmpty)
Container( SliverToBoxAdapter(
padding: const EdgeInsets.all(16), child: Container(
child: Column( padding: const EdgeInsets.all(16),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
'Available Cards', Text(
style: Theme.of(context).textTheme.titleSmall?.copyWith( 'dashboardAvailableCards'.tr(),
fontWeight: FontWeight.bold, style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
), const SizedBox(height: 8),
const SizedBox(height: 8), Wrap(
Wrap( spacing: 8,
spacing: 8, runSpacing: 8,
runSpacing: 8, children: availableCards.map((cardId) {
children: availableCards.map((cardId) { final metadata =
final metadata = cardMetadata[cardId] ??
_cardMetadata[cardId] ?? {'name': cardId, 'icon': Symbols.help};
{'name': cardId, 'icon': Symbols.help}; return ActionChip(
return ActionChip( avatar: Icon(
avatar: Icon( metadata['icon'] as IconData,
metadata['icon'] as IconData, size: 16,
size: 16, color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, ),
), label: Text(metadata['name'] as String),
label: Text(metadata['name'] as String), onPressed: () {
onPressed: () { layouts.value = [...layouts.value, cardId];
layouts.value = [...layouts.value, cardId]; },
}, );
); }).toList(),
}).toList(), ),
), ],
], ),
), ),
), ),
// Divider
const SliverToBoxAdapter(child: Divider()),
// Reset tile
SliverToBoxAdapter(
child: ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(
Symbols.restore,
color: Theme.of(context).colorScheme.primary,
),
title: Text('dashboardResetToDefaults'.tr()),
subtitle: Text('dashboardResetToDefaultsSubtitle'.tr()),
trailing: Icon(
Symbols.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onTap: () async {
final confirmed = await showConfirmAlert(
'dashboardResetConfirmMessage'.tr(),
'dashboardResetConfirmTitle'.tr(),
isDanger: true,
);
if (confirmed) {
ref.read(appSettingsProvider.notifier).resetDashboardConfig();
if (context.mounted) {
Navigator.of(context).pop(); // Close the sheet
}
}
},
),
),
// Divider
const SliverToBoxAdapter(child: Divider()),
// Settings checkboxes
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'dashboardDisplaySettings'.tr(),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
).padding(horizontal: 24, top: 12, bottom: 8),
CheckboxListTile(
dense: true,
title: Text('dashboardShowSearchBar'.tr()),
value: showSearchBar.value,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
if (value != null) {
showSearchBar.value = value;
}
},
),
CheckboxListTile(
dense: true,
title: Text('dashboardShowClockAndCountdown'.tr()),
value: showClockAndCountdown.value,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
if (value != null) {
showClockAndCountdown.value = value;
}
},
),
],
),
),
], ],
); );
} }