💄 Customizable dashboard

This commit is contained in:
2026-01-17 15:32:08 +08:00
parent c5d667ecf3
commit 6a0f351114
8 changed files with 849 additions and 101 deletions

View File

@@ -31,6 +31,8 @@ import 'package:island/widgets/share/share_sheet.dart';
import 'dart:async';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/screens/dashboard/dash_customize.dart';
import 'package:island/pods/config.dart';
class DashboardScreen extends HookConsumerWidget {
const DashboardScreen({super.key});
@@ -44,6 +46,106 @@ class DashboardScreen extends HookConsumerWidget {
}
}
// Helper functions for dynamic dashboard rendering
class DashboardRenderer {
// Map individual card IDs to widgets
static Widget buildCard(String cardId, WidgetRef ref) {
switch (cardId) {
case 'checkIn':
return CheckInWidget(margin: EdgeInsets.zero);
case 'fortuneGraph':
return Card(
margin: EdgeInsets.zero,
child: FortuneGraphWidget(
events: ref.watch(
eventCalendarProvider(
EventCalendarQuery(
uname: 'me',
year: DateTime.now().year,
month: DateTime.now().month,
),
),
),
),
);
case 'fortuneCard':
return FortuneCard(unlimited: true);
case 'postFeatured':
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: PostFeaturedList(),
);
case 'friendsOverview':
return FriendsOverviewWidget();
case 'notifications':
return NotificationsCard();
case 'chatList':
return ChatListCard();
default:
return const SizedBox.shrink();
}
}
// Map column group IDs to column widgets
static Widget buildColumn(String columnId, WidgetRef ref) {
switch (columnId) {
case 'activityColumn':
return SizedBox(
width: 400,
child: Column(
spacing: 16,
children: [
CheckInWidget(margin: EdgeInsets.zero),
Card(
margin: EdgeInsets.zero,
child: FortuneGraphWidget(
events: ref.watch(
eventCalendarProvider(
EventCalendarQuery(
uname: 'me',
year: DateTime.now().year,
month: DateTime.now().month,
),
),
),
),
),
Expanded(child: FortuneCard()),
],
),
);
case 'postsColumn':
return SizedBox(
width: 400,
child: PostFeaturedList(collapsable: false),
);
case 'socialColumn':
return SizedBox(
width: 400,
child: Column(
spacing: 16,
children: [
FriendsOverviewWidget(),
Expanded(child: NotificationsCard()),
],
),
);
case 'chatsColumn':
return SizedBox(
width: 400,
child: Column(
spacing: 16,
children: [Expanded(child: ChatListCard())],
),
);
default:
return const SizedBox.shrink();
}
}
}
class DashboardGrid extends HookConsumerWidget {
const DashboardGrid({super.key});
@@ -148,6 +250,42 @@ class DashboardGrid extends HookConsumerWidget {
],
),
),
// Customize button
Positioned(
bottom: 16,
right: 16,
child: TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => const DashboardCustomizationSheet(),
);
},
icon: Icon(
Symbols.tune,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
label: Text(
'customize',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.surface,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
),
if (dragging.value)
Positioned.fill(
child: Container(
@@ -189,55 +327,26 @@ class _DashboardGridWide extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
final appSettings = ref.watch(appSettingsProvider);
final List<Widget> children = [];
// Always include account unactivated card if user is not activated
if (userInfo.value != null && userInfo.value?.activatedAt == null) {
children.add(SizedBox(width: 400, child: AccountUnactivatedCard()));
}
// Add configured columns in the specified order
final horizontalLayouts = appSettings.dashboardConfig?.horizontalLayouts ??
['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn'];
for (final columnId in horizontalLayouts) {
children.add(DashboardRenderer.buildColumn(columnId, ref));
}
return Row(
spacing: 16,
children: [
if (userInfo.value != null && userInfo.value?.activatedAt == null)
SizedBox(width: 400, child: AccountUnactivatedCard()),
SizedBox(
width: 400,
child: Column(
spacing: 16,
children: [
CheckInWidget(margin: EdgeInsets.zero),
Card(
margin: EdgeInsets.zero,
child: FortuneGraphWidget(
events: ref.watch(
eventCalendarProvider(
EventCalendarQuery(
uname: 'me',
year: DateTime.now().year,
month: DateTime.now().month,
),
),
),
),
),
Expanded(child: FortuneCard()),
],
),
),
SizedBox(width: 400, child: PostFeaturedList(collapsable: false)),
SizedBox(
width: 400,
child: Column(
spacing: 16,
children: [
FriendsOverviewWidget(),
Expanded(child: NotificationsCard()),
],
),
),
SizedBox(
width: 400,
child: Column(
spacing: 16,
children: [Expanded(child: ChatListCard())],
),
),
],
children: children,
);
}
}
@@ -248,36 +357,26 @@ class _DashboardGridNarrow extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userInfo = ref.watch(userInfoProvider);
final appSettings = ref.watch(appSettingsProvider);
final List<Widget> children = [];
// Always include account unactivated card if user is not activated
if (userInfo.value != null && userInfo.value?.activatedAt == null) {
children.add(AccountUnactivatedCard());
}
// Add configured cards in the specified order
final verticalLayouts = appSettings.dashboardConfig?.verticalLayouts ??
['checkIn', 'fortuneCard', 'postFeatured', 'friendsOverview', 'notifications', 'chatList', 'fortuneGraph'];
for (final cardId in verticalLayouts) {
children.add(DashboardRenderer.buildCard(cardId, ref));
}
return Column(
spacing: 16,
children: [
if (userInfo.value != null && userInfo.value?.activatedAt == null)
AccountUnactivatedCard(),
CheckInWidget(margin: EdgeInsets.zero),
FortuneCard(unlimited: true),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: PostFeaturedList(),
),
FriendsOverviewWidget(),
NotificationsCard(),
ChatListCard(),
Card(
margin: EdgeInsets.zero,
child: FortuneGraphWidget(
events: ref.watch(
eventCalendarProvider(
EventCalendarQuery(
uname: 'me',
year: DateTime.now().year,
month: DateTime.now().month,
),
),
),
),
),
],
children: children,
);
}
}
@@ -685,4 +784,4 @@ class _UnauthorizedCard extends HookConsumerWidget {
),
);
}
}
}

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/pods/config.dart';
class DashboardCustomizationSheet extends HookConsumerWidget {
const DashboardCustomizationSheet({super.key});
static const Map<String, Map<String, dynamic>> _cardMetadata = {
// Vertical layout cards
'checkIn': {'name': 'Check In', 'icon': Symbols.check_circle},
'fortuneGraph': {'name': 'Fortune Graph', 'icon': Symbols.show_chart},
'fortuneCard': {'name': 'Fortune', 'icon': Symbols.lightbulb},
'postFeatured': {'name': 'Featured Posts', 'icon': Symbols.article},
'friendsOverview': {'name': 'Friends', 'icon': Symbols.group},
'notifications': {'name': 'Notifications', 'icon': Symbols.notifications},
'chatList': {'name': 'Chats', 'icon': Symbols.chat},
// Horizontal layout columns
'activityColumn': {'name': 'Activity Column', 'icon': Symbols.dashboard, 'description': 'Check In, Fortune Graph & Fortune'},
'postsColumn': {'name': 'Posts Column', 'icon': Symbols.article, 'description': 'Featured Posts'},
'socialColumn': {'name': 'Social Column', 'icon': Symbols.group, 'description': 'Friends & Notifications'},
'chatsColumn': {'name': 'Chats Column', 'icon': Symbols.chat, 'description': 'Recent Chats'},
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2);
final appSettings = ref.watch(appSettingsProvider);
// Local state for editing
final verticalLayouts = useState<List<String>>(
(appSettings.dashboardConfig?.verticalLayouts ?? [
'checkIn',
'fortuneCard',
'postFeatured',
'friendsOverview',
'notifications',
'chatList',
'fortuneGraph',
]).where((id) => id != 'accountUnactivated').toList(),
);
final horizontalLayouts = useState<List<String>>(
_migrateHorizontalLayouts(appSettings.dashboardConfig?.horizontalLayouts),
);
final showSearchBar = useState<bool>(
appSettings.dashboardConfig?.showSearchBar ?? true,
);
final showClockAndCountdown = useState<bool>(
appSettings.dashboardConfig?.showClockAndCountdown ?? true,
);
void saveConfig() {
final config = DashboardConfig(
verticalLayouts: verticalLayouts.value,
horizontalLayouts: horizontalLayouts.value,
showSearchBar: showSearchBar.value,
showClockAndCountdown: showClockAndCountdown.value,
);
ref.read(appSettingsProvider.notifier).setDashboardConfig(config);
Navigator.of(context).pop();
}
return SheetScaffold(
titleText: 'Customize Dashboard',
actions: [TextButton(onPressed: saveConfig, child: const Text('Save'))],
child: Column(
children: [
TabBar(
controller: tabController,
tabs: const [
Tab(text: 'Vertical'),
Tab(text: 'Horizontal'),
],
),
Expanded(
child: TabBarView(
controller: tabController,
children: [
// Vertical layout
_buildLayoutEditor(context, 'Vertical Layout', verticalLayouts, false),
// Horizontal layout
_buildLayoutEditor(
context,
'Horizontal Layout',
horizontalLayouts,
true,
),
],
),
),
const Divider(),
// Settings checkboxes
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Display Settings',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
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;
}
},
),
],
),
),
],
),
);
}
List<String> _migrateHorizontalLayouts(List<String>? existingLayouts) {
if (existingLayouts == null || existingLayouts.isEmpty) {
// Default horizontal layout using column groups
return ['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn'];
}
// If it already contains column groups, use as-is
if (existingLayouts.any((id) => id.contains('Column'))) {
return existingLayouts.where((id) => id != 'accountUnactivated').toList();
}
// Migrate from old individual card format to column groups
// This is a simple migration - in a real app you might want more sophisticated logic
return ['activityColumn', 'postsColumn', 'socialColumn', 'chatsColumn'];
}
Widget _buildLayoutEditor(
BuildContext context,
String title,
ValueNotifier<List<String>> layouts,
bool isHorizontal,
) {
// Filter available cards based on layout mode
final relevantCards = isHorizontal
? _cardMetadata.entries.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 =
relevantCards.where((cardId) => !layouts.value.contains(cardId)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
// Reorderable list for cards
Expanded(
child: ReorderableListView.builder(
buildDefaultDragHandles: false,
itemCount: layouts.value.length,
onReorder: (oldIndex, newIndex) {
if (oldIndex < newIndex) {
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(
key: ValueKey(cardId),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: Icon(
metadata['icon'] as IconData,
color: Theme.of(context).colorScheme.primary,
),
title: Text(metadata['name'] as String),
subtitle: isHorizontal && metadata.containsKey('description')
? Text(
metadata['description'] as String,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
ReorderableDragStartListener(
index: index,
child: Icon(
Symbols.drag_handle,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: Icon(
Symbols.close,
size: 20,
color: Theme.of(context).colorScheme.error,
),
onPressed: () {
layouts.value = layouts.value.where((id) => id != cardId).toList();
},
),
],
),
),
);
},
),
),
// Available cards to add back
if (availableCards.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Available Cards',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: availableCards.map((cardId) {
final metadata =
_cardMetadata[cardId] ??
{'name': cardId, 'icon': Symbols.help};
return ActionChip(
avatar: Icon(
metadata['icon'] as IconData,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
label: Text(metadata['name'] as String),
onPressed: () {
layouts.value = [...layouts.value, cardId];
},
);
}).toList(),
),
],
),
),
],
);
}
}