🎨 Use feature based folder structure

This commit is contained in:
2026-02-06 00:37:02 +08:00
parent 62a3ea26e3
commit 862e3b451b
539 changed files with 8406 additions and 5056 deletions

404
lib/settings/about.dart Normal file
View File

@@ -0,0 +1,404 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/core/services/udid.dart' as udid;
import 'package:island/shared/widgets/alert.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key});
@override
ConsumerState<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends ConsumerState<AboutScreen> {
PackageInfo _packageInfo = PackageInfo(
appName: 'Solian',
packageName: 'dev.solsynth.solian',
version: '1.0.0',
buildNumber: '1',
);
BaseDeviceInfo? _deviceInfo;
String? _deviceUdid;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initPackageInfo();
_initDeviceInfo();
}
Future<void> _initPackageInfo() async {
try {
final info = await PackageInfo.fromPlatform();
if (mounted) {
setState(() {
_packageInfo = info;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'aboutScreenFailedToLoadPackageInfo'.tr(
args: [e.toString()],
);
_isLoading = false;
});
}
}
}
Future<void> _initDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
_deviceInfo = await deviceInfoPlugin.deviceInfo;
_deviceUdid = await udid.getUdid();
if (mounted) {
setState(() {});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'aboutScreenFailedToLoadDeviceInfo'.tr(
args: [e.toString()],
);
});
}
}
}
Future<void> _launchURL(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(title: Text('about'.tr()), elevation: 0),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _errorMessage != null
? Center(child: Text(_errorMessage!))
: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 24),
// App Icon and Name
CircleAvatar(
radius: 50,
backgroundColor: theme.colorScheme.primary.withOpacity(
0.1,
),
child: Image.asset(
'assets/icons/icon.png',
width: 56,
height: 56,
),
),
const SizedBox(height: 16),
Text(
_packageInfo.appName,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
'aboutScreenVersionInfo'.tr(
args: [
_packageInfo.version,
_packageInfo.buildNumber,
],
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
),
const SizedBox(height: 32),
// App Info Card
_buildSection(
context,
title: 'aboutScreenAppInfoSectionTitle'.tr(),
children: [
_buildInfoItem(
context,
icon: Symbols.info,
label: 'aboutScreenPackageNameLabel'.tr(),
value: _packageInfo.packageName,
),
_buildInfoItem(
context,
icon: Symbols.update,
label: 'aboutScreenVersionLabel'.tr(),
value: _packageInfo.version,
),
_buildInfoItem(
context,
icon: Symbols.build,
label: 'aboutScreenBuildNumberLabel'.tr(),
value: _packageInfo.buildNumber,
),
],
),
if (_deviceInfo != null) const SizedBox(height: 16),
if (_deviceInfo != null)
_buildSection(
context,
title: 'Device Information',
children: [
FutureBuilder<String>(
future: udid.getDeviceName(),
builder: (context, snapshot) {
final value = snapshot.hasData
? snapshot.data!
: 'unknown'.tr();
return _buildInfoItem(
context,
icon: Symbols.label,
label: 'aboutDeviceName'.tr(),
value: value,
);
},
),
_buildInfoItem(
context,
icon: Symbols.fingerprint,
label: 'aboutDeviceIdentifier'.tr(),
value: _deviceUdid ?? 'N/A',
copyable: true,
),
],
),
const SizedBox(height: 16),
// Links Card
_buildSection(
context,
title: 'aboutScreenLinksSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Symbols.privacy_tip,
title: 'aboutScreenPrivacyPolicyTitle'.tr(),
onTap: () => _launchURL(
'https://solsynth.dev/terms/privacy-policy',
),
),
_buildListTile(
context,
icon: Symbols.description,
title: 'aboutScreenTermsOfServiceTitle'.tr(),
onTap: () => _launchURL(
'https://solsynth.dev/terms/user-agreement',
),
),
_buildListTile(
context,
icon: Symbols.code,
title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
onTap: () {
showLicensePage(
context: context,
applicationName: _packageInfo.appName,
applicationVersion:
'Version ${_packageInfo.version}',
);
},
),
],
),
const SizedBox(height: 16),
// Developer Info
_buildSection(
context,
title: 'aboutScreenDeveloperSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Symbols.email,
title: 'aboutScreenContactUsTitle'.tr(),
subtitle: 'lily@solsynth.dev',
onTap: () => _launchURL('mailto:lily@solsynth.dev'),
),
_buildListTile(
context,
icon: Symbols.copyright,
title: 'aboutScreenLicenseTitle'.tr(),
subtitle: 'aboutScreenLicenseContent'.tr(),
onTap: () => _launchURL(
'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt',
),
),
if (kIsWeb || !(Platform.isMacOS || Platform.isIOS))
_buildListTile(
context,
icon: Symbols.favorite,
title: 'donate'.tr(),
subtitle: 'donateDescription'.tr(),
onTap: () {
launchUrlString(
'https://afdian.com/@littlesheep',
);
},
),
],
),
const SizedBox(height: 32),
// Copyright
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(
'aboutScreenCopyright'.tr(
args: [DateTime.now().year.toString()],
),
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
const Gap(1),
Text(
'aboutScreenMadeWith'.tr(),
textAlign: TextAlign.center,
).fontSize(10).opacity(0.8),
],
),
),
Gap(MediaQuery.of(context).padding.bottom + 16),
],
),
),
),
),
);
}
Widget _buildSection(
BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
const Divider(height: 1),
...children,
],
),
);
}
Widget _buildInfoItem(
BuildContext context, {
required IconData icon,
required String label,
required String value,
bool copyable = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, size: 20, color: Theme.of(context).hintColor),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 2),
SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: copyable ? 1 : null,
),
],
),
),
if (value.startsWith('http') || value.contains('@') || copyable)
IconButton(
icon: const Icon(Symbols.content_copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
showSnackBar('copiedToClipboard'.tr());
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'copyToClipboardTooltip'.tr(),
),
],
),
);
}
Widget _buildListTile(
BuildContext context, {
required IconData icon,
required String title,
String? subtitle,
required VoidCallback onTap,
}) {
final multipleLines = subtitle?.contains('\n') ?? false;
return Column(
children: [
ListTile(
leading: Icon(icon).padding(top: multipleLines ? 8 : 0),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle) : null,
isThreeLine: multipleLines,
trailing: const Icon(
Symbols.chevron_right,
).padding(top: multipleLines ? 8 : 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
minLeadingWidth: 24,
),
],
);
}
}

View File

@@ -0,0 +1,807 @@
import 'dart:math' as math;
import 'dart:async';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_widgets/account/account_name.dart';
import 'package:island/accounts/accounts_widgets/account/fortune_graph.dart';
import 'package:island/accounts/accounts_widgets/account/friends_overview.dart';
import 'package:island/chat/chat_pod/chat_room.dart';
import 'package:island/chat/chat_pod/chat_summary.dart';
import 'package:island/accounts/event_calendar.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/chat/chat_widgets/chat_room_list_tile.dart';
import 'package:island/core/services/event_bus.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/notifications/notification.dart';
import 'package:island/posts/posts_widgets/post/post_featured.dart';
import 'package:island/shared/widgets/app_scaffold.dart';
import 'package:island/notifications/notification_tile.dart';
import 'package:island/accounts/check_in.dart';
import 'package:island/auth/login_modal.dart';
import 'package:island/core/models/activity.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:slide_countdown/slide_countdown.dart';
import 'package:island/core/widgets/share/share_sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/settings/dashboard/dash_customize.dart';
import 'package:island/core/config.dart';
class DashboardScreen extends HookConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
isNoBackground: false,
body: Center(child: DashboardGrid()),
);
}
}
// 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: LayoutBuilder(
builder: (context, constraints) {
return PostFeaturedList(
collapsable: false,
maxHeight: constraints.maxHeight,
);
},
),
);
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});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
final devicePadding = MediaQuery.paddingOf(context);
final userInfo = ref.watch(userInfoProvider);
final appSettings = ref.watch(appSettingsProvider);
final dragging = useState(false);
return DropTarget(
onDragDone: (detail) {
dragging.value = false;
if (detail.files.isNotEmpty) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ShareSheet.files(files: detail.files),
);
}
},
onDragEntered: (_) => dragging.value = true,
onDragExited: (_) => dragging.value = false,
child: Stack(
children: [
Container(
constraints: BoxConstraints(
maxHeight: isWide
? math.min(640, MediaQuery.sizeOf(context).height * 0.65)
: MediaQuery.sizeOf(context).height,
),
padding: isWide
? EdgeInsets.only(top: devicePadding.top)
: EdgeInsets.only(top: 24 + devicePadding.top),
child: Column(
spacing: 16,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Clock card spans full width (only if enabled in settings)
if (isWide &&
(appSettings.dashboardConfig?.showClockAndCountdown ??
true))
ClockCard().padding(horizontal: 24)
else if (!isWide)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Gap(8),
if (appSettings.dashboardConfig?.showClockAndCountdown ??
true)
Expanded(child: ClockCard(compact: true)),
if (appSettings.dashboardConfig?.showSearchBar ?? true)
IconButton(
onPressed: () {
eventBus.fire(CommandPaletteTriggerEvent());
},
icon: const Icon(Symbols.search),
tooltip: 'searchAnything'.tr(),
),
],
).padding(horizontal: 24),
// Row with two cards side by side (only if enabled in settings)
if (isWide &&
(appSettings.dashboardConfig?.showSearchBar ?? true))
Padding(
padding: EdgeInsets.symmetric(horizontal: isWide ? 24 : 16),
child: SearchBar(
hintText: 'searchAnything'.tr(),
constraints: const BoxConstraints(minHeight: 56),
leading: const Icon(
Symbols.search,
).padding(horizontal: 24),
readOnly: true,
onTap: () {
eventBus.fire(CommandPaletteTriggerEvent());
},
),
),
if (userInfo.value != null)
Expanded(
child:
SingleChildScrollView(
padding: isWide
? const EdgeInsets.symmetric(horizontal: 24)
: EdgeInsets.only(
bottom: 64 + devicePadding.bottom,
),
scrollDirection: isWide
? Axis.horizontal
: Axis.vertical,
child: isWide
? _DashboardGridWide()
: _DashboardGridNarrow(),
)
.clipRRect(
topLeft: isWide ? 0 : 12,
topRight: isWide ? 0 : 12,
)
.padding(horizontal: isWide ? 0 : 16),
)
else
Center(
child: _UnauthorizedCard(isWide: isWide),
).padding(horizontal: isWide ? 24 : 16),
],
),
),
// Customize button
Positioned(
bottom: isWide ? 16 : 16 + devicePadding.bottom,
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,
),
).tr(),
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(
color: Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.9),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.upload_file,
size: 64,
color: Theme.of(context).colorScheme.primary,
),
const Gap(16),
Text(
'dropToShare'.tr(),
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
],
),
);
}
}
class _DashboardGridWide extends HookConsumerWidget {
const _DashboardGridWide();
@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));
}
// If no children, add a SizedBox.expand to maintain width
if (children.isEmpty) {
children.add(SizedBox(width: MediaQuery.sizeOf(context).width));
}
return Row(spacing: 16, children: children);
}
}
class _DashboardGridNarrow extends HookConsumerWidget {
const _DashboardGridNarrow();
@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: children);
}
}
class ClockCard extends HookConsumerWidget {
final bool compact;
const ClockCard({super.key, this.compact = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final time = useState(DateTime.now());
final timer = useRef<Timer?>(null);
final notableDay = ref.watch(recentNotableDayProvider);
// Determine icon based on time of day
final int hour = time.value.hour;
final IconData timeIcon = (hour >= 6 && hour < 18)
? Symbols.sunny_rounded
: Symbols.dark_mode_rounded;
useEffect(() {
timer.value = Timer.periodic(const Duration(seconds: 1), (_) {
time.value = DateTime.now();
});
return () => timer.value?.cancel();
}, []);
return Card(
elevation: 0,
margin: EdgeInsets.zero,
color: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Padding(
padding: compact
? EdgeInsets.zero
: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
timeIcon,
size: 32,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
spacing: 8,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.ideographic,
children: [
Flexible(
child: Text(
'${time.value.hour.toString().padLeft(2, '0')}:${time.value.minute.toString().padLeft(2, '0')}:${time.value.second.toString().padLeft(2, '0')}',
style: GoogleFonts.robotoMono(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
Flexible(
child: Text(
'${time.value.month.toString().padLeft(2, '0')}/${time.value.day.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
),
],
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
spacing: 5,
children: [
notableDay.when(
data: (day) => day == null
? Text('unauthorized').tr()
: _buildNotableDayText(context, day),
error: (err, _) =>
Text(err.toString()).fontSize(12),
loading: () =>
const Text('loading').tr().fontSize(12),
),
],
),
),
],
),
),
],
),
],
),
),
);
}
Widget _buildNotableDayText(BuildContext context, SnNotableDay notableDay) {
final today = DateTime.now();
final isToday =
notableDay.date.year == today.year &&
notableDay.date.month == today.month &&
notableDay.date.day == today.day;
if (isToday) {
return Row(
spacing: 5,
children: [
Text('notableDayToday').tr(args: [notableDay.localName]).fontSize(12),
Icon(
Symbols.celebration_rounded,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
],
);
} else {
return Row(
spacing: 5,
children: [
Text('notableDayNext').tr(args: [notableDay.localName]).fontSize(12),
SlideCountdown(
decoration: const BoxDecoration(),
style: const TextStyle(fontSize: 12),
separatorStyle: const TextStyle(fontSize: 12),
padding: EdgeInsets.zero,
duration: notableDay.date.difference(DateTime.now()),
),
],
);
}
}
}
class NotificationsCard extends HookConsumerWidget {
const NotificationsCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifications = ref.watch(notificationListProvider);
final notificationsUnreadCount = ref.watch(notificationUnreadCountProvider);
return Card(
margin: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: InkWell(
borderRadius: BorderRadius.all(Radius.circular(12)),
onTap: () {
// Show notification sheet similar to explore.dart
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => const NotificationSheet(),
);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.notifications,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'notifications'.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Badge.count(
count: notificationsUnreadCount.value ?? 0,
isLabelVisible: (notificationsUnreadCount.value ?? 0) > 0,
),
],
).padding(horizontal: 16, vertical: 12),
notifications.when(
loading: () => const SkeletonNotificationTile(),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (notificationList) {
if (notificationList.items.isEmpty) {
return Center(child: Text('noNotificationsYet').tr());
}
// Get the most recent notification (first in the list)
final recentNotification = notificationList.items.first;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'mostRecent'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
).padding(horizontal: 16),
const SizedBox(height: 8),
NotificationTile(
notification: recentNotification,
compact: true,
contentPadding: EdgeInsets.symmetric(horizontal: 16),
avatarRadius: 16.0,
),
],
);
},
),
Text(
'tapToViewAllNotifications'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
).padding(horizontal: 16, vertical: 8),
],
),
),
);
}
}
class ChatListCard extends HookConsumerWidget {
const ChatListCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final chatRooms = ref.watch(chatRoomJoinedProvider);
final chatUnreadCount = ref.watch(chatUnreadCountProvider);
return Card(
margin: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.chat,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'recentChats'.tr(),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Badge.count(
count: chatUnreadCount.value ?? 0,
isLabelVisible: (chatUnreadCount.value ?? 0) > 0,
),
],
).padding(horizontal: 16, vertical: 16),
chatRooms.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (rooms) {
if (rooms.isEmpty) {
return const Center(child: Text('No chat rooms available'));
}
// Take only the first 5 rooms
final recentRooms = rooms.take(5).toList();
return Column(
children: recentRooms.map((room) {
return ChatRoomListTile(
room: room,
isDirect: room.type == 1,
onTap: () {
context.pushNamed(
'chatRoom',
pathParameters: {'id': room.id},
);
},
);
}).toList(),
);
},
),
],
),
);
}
}
class FortuneCard extends HookConsumerWidget {
final bool unlimited;
const FortuneCard({super.key, this.unlimited = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fortuneAsync = ref.watch(randomFortuneSayingProvider);
final child = Card(
margin: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: fortuneAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (fortune) {
return Row(
spacing: 8,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
fortune.content,
maxLines: unlimited ? null : 2,
overflow: TextOverflow.fade,
),
),
Text('—— ${fortune.source}').bold(),
],
).padding(horizontal: 16, vertical: unlimited ? 12 : 0);
},
),
);
if (unlimited) return child;
return child.height(48);
}
}
class _UnauthorizedCard extends HookConsumerWidget {
final bool isWide;
const _UnauthorizedCard({required this.isWide});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isWide ? 48 : 32,
vertical: 32,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Gap(16),
const SizedBox(width: double.infinity),
Icon(
Symbols.dashboard_rounded,
size: 64,
color: Theme.of(context).colorScheme.primary,
fill: 1,
),
const Gap(16),
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const Gap(8),
Text(
'Login to access your personalized dashboard with friends, notifications, chats, and more!',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const Gap(12),
FilledButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const LoginModal(),
);
},
icon: const Icon(Symbols.login),
label: Text('login').tr(),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,398 @@
import 'package:easy_localization/easy_localization.dart';
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/core/widgets/content/sheet.dart';
import 'package:island/core/config.dart';
import 'package:island/shared/widgets/alert.dart';
import 'package:styled_widget/styled_widget.dart';
class DashboardCustomizationSheet extends HookConsumerWidget {
const DashboardCustomizationSheet({super.key});
static Map<String, Map<String, dynamic>> _getCardMetadata(
BuildContext context,
) {
return {
// Vertical layout cards
'checkIn': {
'name': 'dashboardCardCheckIn'.tr(),
'icon': Symbols.check_circle,
},
'fortuneGraph': {
'name': 'dashboardCardFortuneGraph'.tr(),
'icon': Symbols.show_chart,
},
'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
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: 'dashboardCustomizeTitle'.tr(),
actions: [IconButton(onPressed: saveConfig, icon: Icon(Symbols.save))],
child: DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
controller: tabController,
tabs: [
Tab(text: 'dashboardTabVertical'.tr()),
Tab(text: 'dashboardTabHorizontal'.tr()),
],
),
Expanded(
child: CustomScrollView(
slivers: [
SliverFillRemaining(
child: TabBarView(
controller: tabController,
children: [
// Vertical layout
_buildSliverLayoutEditor(
context,
ref,
'dashboardLayoutVertical'.tr(),
verticalLayouts,
false,
showSearchBar,
showClockAndCountdown,
),
// Horizontal layout
_buildSliverLayoutEditor(
context,
ref,
'dashboardLayoutHorizontal'.tr(),
horizontalLayouts,
true,
showSearchBar,
showClockAndCountdown,
),
],
),
),
],
),
),
],
),
),
);
}
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 _buildSliverLayoutEditor(
BuildContext context,
WidgetRef ref,
String title,
ValueNotifier<List<String>> layouts,
bool isHorizontal,
ValueNotifier<bool> showSearchBar,
ValueNotifier<bool> showClockAndCountdown,
) {
final cardMetadata = _getCardMetadata(context);
// 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 CustomScrollView(
slivers: [
// Title
SliverToBoxAdapter(
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
).padding(horizontal: 24, top: 16, bottom: 8),
),
// Reorderable list for cards
SliverReorderableList(
itemCount: layouts.value.length,
itemBuilder: (context, index) {
final cardId = layouts.value[index];
final metadata =
cardMetadata[cardId] ?? {'name': cardId, 'icon': Symbols.help};
return ReorderableDragStartListener(
key: ValueKey(cardId),
index: index,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
dense: true,
leading: Icon(
metadata['icon'] as IconData,
color: Theme.of(context).colorScheme.primary,
),
contentPadding: const EdgeInsets.fromLTRB(16, 0, 8, 0),
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: [
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();
},
),
],
),
),
),
);
},
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
if (availableCards.isNotEmpty)
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'dashboardAvailableCards'.tr(),
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(),
),
],
),
),
),
// 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;
}
},
),
],
),
),
],
);
}
}

1145
lib/settings/settings.dart Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,247 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/accounts/accounts_pod.dart';
import 'package:island/core/services/responsive.dart';
import 'package:island/drive/drive_widgets/cloud_files.dart';
import 'package:island/core/navigation/conditional_bottom_nav.dart';
import 'package:island/notifications/notification.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:island/chat/chat_pod/chat_summary.dart';
import 'package:styled_widget/styled_widget.dart';
final currentRouteProvider = NotifierProvider<CurrentRouteNotifier, String?>(
CurrentRouteNotifier.new,
);
class CurrentRouteNotifier extends Notifier<String?> {
@override
String? build() {
return null;
}
void updateRoute(String? route) {
state = route;
}
}
const kWideScreenRouteStart = 5;
const kTabRoutes = [
'/',
'/explore',
'/chat',
'/realms',
'/account',
'/files',
'/thought',
'/creators',
'/developers',
];
class TabsScreen extends HookConsumerWidget {
final Widget? child;
const TabsScreen({super.key, this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
// final useHorizontalLayout = isWideScreen(context);
final currentLocation = GoRouterState.of(context).uri.toString();
// Update the current route provider whenever the location changes
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(currentRouteProvider.notifier).updateRoute(currentLocation);
});
return null;
}, [currentLocation]);
final notificationUnreadCount = ref.watch(notificationUnreadCountProvider);
final chatUnreadCount = ref.watch(chatUnreadCountProvider);
final wideScreen = isWideScreen(context);
final destinations = [
NavigationDestination(
label: 'dashboard'.tr(),
icon: const Icon(Symbols.dashboard_rounded),
),
NavigationDestination(
label: 'explore'.tr(),
icon: const Icon(Symbols.explore_rounded),
),
NavigationDestination(
label: 'chat'.tr(),
icon: Badge.count(
count: chatUnreadCount.value ?? 0,
isLabelVisible: (chatUnreadCount.value ?? 0) > 0,
child: const Icon(Symbols.forum_rounded),
),
),
NavigationDestination(
label: 'realms'.tr(),
icon: const Icon(Symbols.groups_3),
),
NavigationDestination(
label: 'account'.tr(),
icon: Badge.count(
count: notificationUnreadCount.value ?? 0,
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
child: Consumer(
child: const Icon(Symbols.account_circle_rounded),
builder: (context, ref, fallbackChild) {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value?.profile.picture != null) {
return ProfilePictureWidget(
file: userInfo.value!.profile.picture,
radius: 12,
);
}
return fallbackChild!;
},
),
),
),
if (wideScreen)
...([
NavigationDestination(
label: 'files'.tr(),
icon: const Icon(Symbols.folder_rounded),
),
NavigationDestination(
label: 'aiThought'.tr(),
icon: const Icon(Symbols.bubble_chart),
),
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.design_services_rounded),
),
NavigationDestination(
label: 'developerHub'.tr(),
icon: const Icon(Symbols.data_object_rounded),
),
]),
];
int getCurrentIndex() {
if (currentLocation == '/') return 0;
final idx = kTabRoutes.indexWhere(
(p) => currentLocation.startsWith(p),
1,
);
final value = math.max(idx, 0);
return math.min(value, destinations.length - 1);
}
void onDestinationSelected(int index) {
context.go(kTabRoutes[index]);
}
final currentIndex = getCurrentIndex();
if (isWideScreen(context)) {
return Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Row(
children: [
NavigationRail(
backgroundColor: Colors.transparent,
destinations: destinations.mapIndexed((idx, d) {
if (d.icon is Icon) {
return NavigationRailDestination(
icon: Icon(
(d.icon as Icon).icon,
fill: currentIndex == idx ? 1 : null,
),
label: Text(d.label),
);
}
return NavigationRailDestination(
icon: d.icon,
label: Text(d.label),
);
}).toList(),
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
),
child: child ?? const SizedBox.shrink(),
),
),
],
),
);
}
return Scaffold(
backgroundColor: Colors.transparent,
extendBody: true,
resizeToAvoidBottomInset: false,
body: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
child: child ?? const SizedBox.shrink(),
),
bottomNavigationBar: ConditionalBottomNav(
child: ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
child: NavigationBar(
height: 56,
destinations: destinations.mapIndexed((idx, d) {
if (d.icon is Icon) {
return NavigationDestination(
icon: Icon(
(d.icon as Icon).icon,
fill: currentIndex == idx ? 1 : null,
),
label: d.label,
);
}
return d;
}).toList(),
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
backgroundColor: Colors.transparent,
indicatorColor: Theme.of(
context,
).colorScheme.primary.withOpacity(0.2),
).padding(horizontal: 12),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';
class TrayService {
TrayService._();
static final TrayService _instance = TrayService._();
static TrayService get instance => _instance;
bool _checkPlatformAvalability() {
if (kIsWeb) return false;
if (Platform.isAndroid || Platform.isIOS) return false;
return true;
}
Future<void> initialize(TrayListener listener) async {
if (!_checkPlatformAvalability()) return;
await trayManager.setIcon(
Platform.isWindows
? 'assets/icons/icon.ico'
: 'assets/icons/icon-tray.png',
isTemplate: Platform.isMacOS,
);
final menu = Menu(
items: [
MenuItem(key: 'show_window', label: 'Show Window'),
MenuItem.separator(),
MenuItem(key: 'exit_app', label: 'Exit App'),
],
);
await trayManager.setContextMenu(menu);
trayManager.addListener(listener);
}
Future<void> dispose(TrayListener listener) async {
if (!_checkPlatformAvalability()) return;
trayManager.removeListener(listener);
await trayManager.destroy();
}
void handleAction(MenuItem item) {
switch (item.key) {
case 'show_window':
windowManager.show();
break;
case 'exit_app':
windowManager.destroy();
exit(0);
}
}
}