🎨 Use feature based folder structure
This commit is contained in:
404
lib/settings/about.dart
Normal file
404
lib/settings/about.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
807
lib/settings/dashboard/dash.dart
Normal file
807
lib/settings/dashboard/dash.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
398
lib/settings/dashboard/dash_customize.dart
Normal file
398
lib/settings/dashboard/dash_customize.dart
Normal 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
1145
lib/settings/settings.dart
Normal file
File diff suppressed because it is too large
Load Diff
247
lib/settings/tabs_screen.dart
Normal file
247
lib/settings/tabs_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/settings/tray_manager.dart
Normal file
58
lib/settings/tray_manager.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user