Compare commits

..

11 Commits

Author SHA1 Message Date
LittleSheep
552b4b2572 🚀 Launch 3.0.0+111 2025-07-03 01:20:54 +08:00
LittleSheep
594ac39e3d Article attachments 2025-07-03 01:18:07 +08:00
LittleSheep
23321171f3 💄 Optimized attachment insert in article compose 2025-07-03 01:11:56 +08:00
LittleSheep
ee72d79c93 Hide attachments list on article 2025-07-03 01:06:01 +08:00
LittleSheep
a20c2598fc 🐛 Fixes render errors of unauthorized 2025-07-03 00:49:11 +08:00
LittleSheep
2eba871a6d 💄 Web article has max width 2025-07-03 00:39:45 +08:00
LittleSheep
46919dec31 📝 Updated about screen 2025-07-03 00:34:11 +08:00
LittleSheep
9dd6cffe0c 🐛 Trying to fix push notification 2025-07-03 00:27:30 +08:00
LittleSheep
2ea9f5e907 💄 Optimized rendering for article post 2025-07-03 00:16:10 +08:00
LittleSheep
050750a808 🐛 Fixes articles bugs 2025-07-02 23:57:07 +08:00
LittleSheep
f479b9fc8b 🐛 Bug fixes on posts writing and etc 2025-07-02 23:34:27 +08:00
31 changed files with 1148 additions and 494 deletions

View File

@@ -375,7 +375,9 @@
"postContent": "Content", "postContent": "Content",
"postSettings": "Settings", "postSettings": "Settings",
"postPublisherUnselected": "Publisher Unspecified", "postPublisherUnselected": "Publisher Unspecified",
"postVisibility": "Visibility", "postType": "Post Type",
"articleAttachmentHint": "Attachments must be uploaded and inserted into the article body to be visible.",
"postVisibility": "Post Visibility",
"postVisibilityPublic": "Public", "postVisibilityPublic": "Public",
"postVisibilityFriends": "Friends Only", "postVisibilityFriends": "Friends Only",
"postVisibilityUnlisted": "Unlisted", "postVisibilityUnlisted": "Unlisted",
@@ -686,9 +688,14 @@
"aboutScreenDeveloperSectionTitle": "Developer", "aboutScreenDeveloperSectionTitle": "Developer",
"aboutScreenContactUsTitle": "Contact Us", "aboutScreenContactUsTitle": "Contact Us",
"aboutScreenLicenseTitle": "License", "aboutScreenLicenseTitle": "License",
"aboutScreenLicenseContent": "All copyright reserved © {} Solsynth\nOpen-sourced under license GNU AGPL v3.0", "aboutScreenLicenseContent": "GNU Affero General Public License v3.0",
"aboutScreenCopyright": "© {} {}. All rights reserved.", "aboutScreenCopyright": "All rights reserved © Solsynth {}",
"aboutScreenMadeWith": "Made with ❤︎️ by Solar Network Team",
"aboutScreenFailedToLoadPackageInfo": "Failed to load package info: {error}", "aboutScreenFailedToLoadPackageInfo": "Failed to load package info: {error}",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"copyToClipboardTooltip": "Copy to clipboard" "copyToClipboardTooltip": "Copy to clipboard",
"postForwardingTo": "Forwarding to",
"postReplyingTo": "Replying to",
"postEditing": "You are editing an existing post",
"postArticle": "Article"
} }

View File

@@ -512,5 +512,33 @@
"orderId": "订单 ID", "orderId": "订单 ID",
"enterOrderId": "输入您的订单 ID", "enterOrderId": "输入您的订单 ID",
"restore": "恢复", "restore": "恢复",
"keyboardShortcuts": "键盘快捷键" "keyboardShortcuts": "键盘快捷键",
"about": "关于",
"membershipCancel": "取消会员订阅",
"membershipCancelConfirm": "您确定要取消您的会员订阅?",
"membershipCancelHint": "您确定要取消您的会员订阅吗?您将不会再被收费。您的会员资格将在当前计费周期结束前保持有效。并且您在当前订阅结束之前无法重新订阅。",
"membershipCancelSuccess": "您的会员订阅已成功取消。",
"aboutScreenTitle": "关于",
"aboutScreenVersionInfo": "版本 {} ({})",
"aboutScreenAppInfoSectionTitle": "应用信息",
"aboutScreenPackageNameLabel": "包名",
"aboutScreenVersionLabel": "版本",
"aboutScreenBuildNumberLabel": "构建编号",
"aboutScreenLinksSectionTitle": "链接",
"aboutScreenPrivacyPolicyTitle": "隐私政策",
"aboutScreenTermsOfServiceTitle": "服务条款",
"aboutScreenOpenSourceLicensesTitle": "开源许可证",
"aboutScreenDeveloperSectionTitle": "开发者",
"aboutScreenContactUsTitle": "联系我们",
"aboutScreenLicenseTitle": "许可证",
"aboutScreenLicenseContent": "GNU Affero General Public License v3.0",
"aboutScreenCopyright": "版权所有 © 索尔辛茨 {}",
"aboutScreenMadeWith": "由 Solar Network Team 用 ❤︎️ 制作",
"aboutScreenFailedToLoadPackageInfo": "加载包信息失败:{error}",
"copiedToClipboard": "已复制到剪贴板",
"copyToClipboardTooltip": "复制到剪贴板",
"postForwardingTo": "转发给",
"postReplyingTo": "回复给",
"postEditing": "您正在编辑现有帖子",
"postArticle": "文章"
} }

View File

@@ -9,6 +9,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker_android/image_picker_android.dart'; import 'package:image_picker_android/image_picker_android.dart';
import 'package:island/firebase_options.dart'; import 'package:island/firebase_options.dart';
@@ -45,6 +46,10 @@ void main() async {
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
} }
if (kIsWeb) {
GoRouter.optionURLReflectsImperativeAPIs = true;
}
try { try {
await EasyLocalization.ensureInitialized(); await EasyLocalization.ensureInitialized();
await Firebase.initializeApp( await Firebase.initializeApp(
@@ -216,7 +221,7 @@ class IslandApp extends HookConsumerWidget {
Future(() { Future(() {
userNotifier.fetchUser().then((_) { userNotifier.fetchUser().then((_) {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
if (user.hasValue) { if (user.value != null) {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
subscribePushNotification(apiClient); subscribePushNotification(apiClient);
final wsNotifier = ref.read(websocketStateProvider.notifier); final wsNotifier = ref.read(websocketStateProvider.notifier);

View File

@@ -18,8 +18,13 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final user = SnAccount.fromJson(response.data); final user = SnAccount.fromJson(response.data);
state = AsyncValue.data(user); state = AsyncValue.data(user);
} catch (error, stackTrace) { } catch (error, stackTrace) {
log("[UserInfo] Failed to fetch user info: $error"); log(
state = AsyncValue.error(error, stackTrace); "[UserInfo] Failed to fetch user info...",
name: 'UserInfoNotifier',
error: error,
stackTrace: stackTrace,
);
state = AsyncValue.data(null);
} }
} }

View File

@@ -65,6 +65,9 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: builder:
(context, state) => PostComposeScreen( (context, state) => PostComposeScreen(
initialState: state.extra as PostComposeInitialState?, initialState: state.extra as PostComposeInitialState?,
type:
int.tryParse(state.uri.queryParameters['type'] ?? '0') ??
0,
), ),
), ),
GoRoute( GoRoute(

View File

@@ -1,24 +1,34 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/udid.native.dart';
import 'package:island/widgets/alert.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
class AboutScreen extends StatefulWidget { class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key}); const AboutScreen({super.key});
@override @override
State<AboutScreen> createState() => _AboutScreenState(); ConsumerState<AboutScreen> createState() => _AboutScreenState();
} }
class _AboutScreenState extends State<AboutScreen> { class _AboutScreenState extends ConsumerState<AboutScreen> {
PackageInfo _packageInfo = PackageInfo( PackageInfo _packageInfo = PackageInfo(
appName: 'Solian', appName: 'Solian',
packageName: 'dev.solsynth.solian', packageName: 'dev.solsynth.solian',
version: '1.0.0', version: '1.0.0',
buildNumber: '1', buildNumber: '1',
); );
BaseDeviceInfo? _deviceInfo;
String? _deviceUdid;
bool _isLoading = true; bool _isLoading = true;
String? _errorMessage; String? _errorMessage;
@@ -26,6 +36,7 @@ class _AboutScreenState extends State<AboutScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_initPackageInfo(); _initPackageInfo();
_initDeviceInfo();
} }
Future<void> _initPackageInfo() async { Future<void> _initPackageInfo() async {
@@ -49,6 +60,25 @@ class _AboutScreenState extends State<AboutScreen> {
} }
} }
Future<void> _initDeviceInfo() async {
try {
final deviceInfoPlugin = DeviceInfoPlugin();
_deviceInfo = await deviceInfoPlugin.deviceInfo;
_deviceUdid = await getUdid();
if (mounted) {
setState(() {});
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'aboutScreenFailedToLoadDeviceInfo'.tr(
args: [e.toString()],
);
});
}
}
}
Future<void> _launchURL(String url) async { Future<void> _launchURL(String url) async {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
@@ -108,25 +138,66 @@ class _AboutScreenState extends State<AboutScreen> {
children: [ children: [
_buildInfoItem( _buildInfoItem(
context, context,
icon: Icons.info_outline, icon: Symbols.info,
label: 'aboutScreenPackageNameLabel'.tr(), label: 'aboutScreenPackageNameLabel'.tr(),
value: _packageInfo.packageName, value: _packageInfo.packageName,
), ),
_buildInfoItem( _buildInfoItem(
context, context,
icon: Icons.update, icon: Symbols.update,
label: 'aboutScreenVersionLabel'.tr(), label: 'aboutScreenVersionLabel'.tr(),
value: _packageInfo.version, value: _packageInfo.version,
), ),
_buildInfoItem( _buildInfoItem(
context, context,
icon: Icons.build, icon: Symbols.build,
label: 'aboutScreenBuildNumberLabel'.tr(), label: 'aboutScreenBuildNumberLabel'.tr(),
value: _packageInfo.buildNumber, value: _packageInfo.buildNumber,
), ),
], ],
), ),
if (_deviceInfo != null) const SizedBox(height: 16),
if (_deviceInfo != null)
_buildSection(
context,
title: 'Device Information',
children: [
_buildInfoItem(
context,
icon: Symbols.label,
label: 'Device Name',
value: _deviceInfo?.data['name'],
),
_buildInfoItem(
context,
icon: Symbols.fingerprint,
label: 'Device Identifier',
value: _deviceUdid ?? 'N/A',
copyable: true,
),
const Divider(height: 1),
_buildListTile(
context,
icon: Symbols.notifications_active,
title: 'Reactivate Push Notifications',
onTap: () async {
showLoadingModal(context);
try {
await subscribePushNotification(
ref.watch(apiClientProvider),
);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
}
},
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// Links Card // Links Card
@@ -136,7 +207,7 @@ class _AboutScreenState extends State<AboutScreen> {
children: [ children: [
_buildListTile( _buildListTile(
context, context,
icon: Icons.privacy_tip_outlined, icon: Symbols.privacy_tip,
title: 'aboutScreenPrivacyPolicyTitle'.tr(), title: 'aboutScreenPrivacyPolicyTitle'.tr(),
onTap: onTap:
() => _launchURL( () => _launchURL(
@@ -145,7 +216,7 @@ class _AboutScreenState extends State<AboutScreen> {
), ),
_buildListTile( _buildListTile(
context, context,
icon: Icons.description_outlined, icon: Symbols.description,
title: 'aboutScreenTermsOfServiceTitle'.tr(), title: 'aboutScreenTermsOfServiceTitle'.tr(),
onTap: onTap:
() => _launchURL( () => _launchURL(
@@ -154,7 +225,7 @@ class _AboutScreenState extends State<AboutScreen> {
), ),
_buildListTile( _buildListTile(
context, context,
icon: Icons.code, icon: Symbols.code,
title: 'aboutScreenOpenSourceLicensesTitle'.tr(), title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
onTap: () { onTap: () {
showLicensePage( showLicensePage(
@@ -177,14 +248,14 @@ class _AboutScreenState extends State<AboutScreen> {
children: [ children: [
_buildListTile( _buildListTile(
context, context,
icon: Icons.email_outlined, icon: Symbols.email,
title: 'aboutScreenContactUsTitle'.tr(), title: 'aboutScreenContactUsTitle'.tr(),
subtitle: 'lily@solsynth.dev', subtitle: 'lily@solsynth.dev',
onTap: () => _launchURL('mailto:lily@solsynth.dev'), onTap: () => _launchURL('mailto:lily@solsynth.dev'),
), ),
_buildListTile( _buildListTile(
context, context,
icon: Icons.copyright, icon: Symbols.copyright,
title: 'aboutScreenLicenseTitle'.tr(), title: 'aboutScreenLicenseTitle'.tr(),
subtitle: 'aboutScreenLicenseContent'.tr( subtitle: 'aboutScreenLicenseContent'.tr(
args: [DateTime.now().year.toString()], args: [DateTime.now().year.toString()],
@@ -202,14 +273,25 @@ class _AboutScreenState extends State<AboutScreen> {
// Copyright // Copyright
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Text( child: Column(
children: [
Text(
'aboutScreenCopyright'.tr( 'aboutScreenCopyright'.tr(
args: [DateTime.now().year.toString(), "Solsynth"], args: [DateTime.now().year.toString()],
), ),
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const Gap(1),
Text(
'aboutScreenMadeWith'.tr(),
textAlign: TextAlign.center,
).fontSize(10).opacity(0.8),
],
), ),
),
Gap(MediaQuery.of(context).padding.bottom + 16),
], ],
), ),
), ),
@@ -247,6 +329,7 @@ class _AboutScreenState extends State<AboutScreen> {
required IconData icon, required IconData icon,
required String label, required String label,
required String value, required String value,
bool copyable = false,
}) { }) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -263,13 +346,14 @@ class _AboutScreenState extends State<AboutScreen> {
SelectableText( SelectableText(
value, value,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
maxLines: copyable ? 1 : null,
), ),
], ],
), ),
), ),
if (value.startsWith('http') || value.contains('@')) if (value.startsWith('http') || value.contains('@') || copyable)
IconButton( IconButton(
icon: const Icon(Icons.copy, size: 16), icon: const Icon(Symbols.content_copy, size: 16),
onPressed: () { onPressed: () {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -301,7 +385,7 @@ class _AboutScreenState extends State<AboutScreen> {
subtitle: subtitle != null ? Text(subtitle) : null, subtitle: subtitle != null ? Text(subtitle) : null,
isThreeLine: multipleLines, isThreeLine: multipleLines,
trailing: const Icon( trailing: const Icon(
Icons.chevron_right, Symbols.chevron_right,
).padding(top: multipleLines ? 8 : 0), ).padding(top: multipleLines ? 8 : 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onTap: onTap, onTap: onTap,

View File

@@ -59,7 +59,7 @@ class AccountScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
); );
if (!user.hasValue || user.value == null) { if (user.value == null || user.value == null) {
return _UnauthorizedAccountScreen(); return _UnauthorizedAccountScreen();
} }
@@ -367,12 +367,23 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
), ),
), ),
const Gap(8), const Gap(8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(
onPressed: () {
context.push('/about');
},
child: Text('about').tr(),
),
TextButton( TextButton(
onPressed: () { onPressed: () {
context.push('/settings'); context.push('/settings');
}, },
child: Text('appSettings').tr(), child: Text('appSettings').tr(),
).center(), ),
],
),
], ],
), ),
).center(), ).center(),

View File

@@ -82,7 +82,7 @@ class EventCalanderScreen extends HookConsumerWidget {
), ),
// Show user profile if viewing someone else's calendar // Show user profile if viewing someone else's calendar
if (name != 'me' && user.hasValue) if (name != 'me' && user.value != null)
AccountNameplate(name: name), AccountNameplate(name: name),
], ],
), ),
@@ -106,7 +106,7 @@ class EventCalanderScreen extends HookConsumerWidget {
).padding(horizontal: 8, vertical: 4), ).padding(horizontal: 8, vertical: 4),
// Show user profile if viewing someone else's calendar // Show user profile if viewing someone else's calendar
if (name != 'me' && user.hasValue) if (name != 'me' && user.value != null)
AccountNameplate(name: name), AccountNameplate(name: name),
Gap(MediaQuery.of(context).padding.bottom + 16), Gap(MediaQuery.of(context).padding.bottom + 16),
], ],

View File

@@ -72,6 +72,8 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async {
@riverpod @riverpod
Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async { Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value == null) return null;
final account = await ref.watch(accountProvider(uname).future); final account = await ref.watch(accountProvider(uname).future);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
try { try {
@@ -87,6 +89,8 @@ Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async {
@riverpod @riverpod
Future<SnRelationship?> accountRelationship(Ref ref, String uname) async { Future<SnRelationship?> accountRelationship(Ref ref, String uname) async {
final userInfo = ref.watch(userInfoProvider);
if (userInfo.value == null) return null;
final account = await ref.watch(accountProvider(uname).future); final account = await ref.watch(accountProvider(uname).future);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
try { try {
@@ -219,6 +223,8 @@ class AccountProfileScreen extends HookConsumerWidget {
]; ];
} }
final user = ref.watch(userInfoProvider);
return account.when( return account.when(
data: data:
(data) => AppScaffold( (data) => AppScaffold(
@@ -379,9 +385,13 @@ class AccountProfileScreen extends HookConsumerWidget {
).padding(horizontal: 24), ).padding(horizontal: 24),
), ),
if (user.value != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: const Divider(height: 1).padding(top: 24, bottom: 12), child: const Divider(
height: 1,
).padding(top: 24, bottom: 12),
), ),
if (user.value != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Row( child: Row(
spacing: 8, spacing: 8,

View File

@@ -51,6 +51,9 @@ class _ArticleDetailContent extends HookConsumerWidget {
); );
return SingleChildScrollView( return SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@@ -100,6 +103,8 @@ class _ArticleDetailContent extends HookConsumerWidget {
), ),
], ],
), ),
),
),
); );
} }
} }

View File

@@ -108,15 +108,18 @@ class CreatorHubShellScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
if (isWide) { if (isWide) {
return Row( return AppBackground(
isRoot: true,
child: Row(
children: [ children: [
SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)), SizedBox(width: 360, child: const CreatorHubScreen(isAside: true)),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: child), Expanded(child: child),
], ],
),
); );
} }
return child; return AppBackground(isRoot: true, child: child);
} }
} }
@@ -198,7 +201,6 @@ class CreatorHubScreen extends HookConsumerWidget {
); );
return AppScaffold( return AppScaffold(
noBackground: false,
appBar: AppBar( appBar: AppBar(
leading: !isWide ? const PageBackButton() : null, leading: !isWide ? const PageBackButton() : null,
title: Text('creatorHub').tr(), title: Text('creatorHub').tr(),
@@ -322,9 +324,7 @@ class CreatorHubScreen extends HookConsumerWidget {
subtitle: Text('createPublisherHint').tr(), subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () {
context.push('/creators/publishers/new').then(( context.push('/creators/new').then((value) {
value,
) {
if (value != null) { if (value != null) {
ref.invalidate(publishersManagedProvider); ref.invalidate(publishersManagedProvider);
} }

View File

@@ -26,7 +26,7 @@ class CreatorPostListScreen extends HookConsumerWidget {
children: [ children: [
ListTile( ListTile(
leading: const Icon(Symbols.edit), leading: const Icon(Symbols.edit),
title: Text('postContent'.tr()), title: Text('Post'),
subtitle: Text('Create a regular post'), subtitle: Text('Create a regular post'),
onTap: () async { onTap: () async {
Navigator.pop(context); Navigator.pop(context);

View File

@@ -47,15 +47,21 @@ class DeveloperHubShellScreen extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
if (isWide) { if (isWide) {
return Row( return AppBackground(
isRoot: true,
child: Row(
children: [ children: [
SizedBox(width: 360, child: const DeveloperHubScreen(isAside: true)), SizedBox(
width: 360,
child: const DeveloperHubScreen(isAside: true),
),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: child), Expanded(child: child),
], ],
),
); );
} }
return child; return AppBackground(isRoot: true, child: child);
} }
} }

View File

@@ -126,7 +126,10 @@ class ArticlesScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(title ?? 'Articles')), appBar: AppBar(title: Text(title ?? 'Articles')),
body: CustomScrollView( body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: CustomScrollView(
slivers: [ slivers: [
SliverPadding( SliverPadding(
padding: const EdgeInsets.only(top: 8, left: 8, right: 8), padding: const EdgeInsets.only(top: 8, left: 8, right: 8),
@@ -137,6 +140,8 @@ class ArticlesScreen extends ConsumerWidget {
), ),
], ],
), ),
),
),
); );
} }
} }

View File

@@ -307,7 +307,7 @@ class _ActivityListView extends HookConsumerWidget {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
if (user.hasValue && !contentOnly) if (user.value != null && !contentOnly)
SliverToBoxAdapter(child: CheckInWidget()), SliverToBoxAdapter(child: CheckInWidget()),
SliverList.builder( SliverList.builder(
itemCount: widgetCount, itemCount: widgetCount,

View File

@@ -33,6 +33,8 @@ sealed class PostComposeInitialState with _$PostComposeInitialState {
String? content, String? content,
@Default([]) List<UniversalFile> attachments, @Default([]) List<UniversalFile> attachments,
int? visibility, int? visibility,
SnPost? replyingTo,
SnPost? forwardingTo,
}) = _PostComposeInitialState; }) = _PostComposeInitialState;
factory PostComposeInitialState.fromJson(Map<String, dynamic> json) => factory PostComposeInitialState.fromJson(Map<String, dynamic> json) =>
@@ -66,23 +68,22 @@ class PostEditScreen extends HookConsumerWidget {
class PostComposeScreen extends HookConsumerWidget { class PostComposeScreen extends HookConsumerWidget {
final SnPost? originalPost; final SnPost? originalPost;
final SnPost? repliedPost;
final SnPost? forwardedPost;
final int? type; final int? type;
final PostComposeInitialState? initialState; final PostComposeInitialState? initialState;
const PostComposeScreen({ const PostComposeScreen({
super.key, super.key,
this.originalPost,
this.repliedPost,
this.forwardedPost,
this.type, this.type,
this.initialState, this.initialState,
this.originalPost,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Determine the compose type: auto-detect from edited post or use query parameter // Determine the compose type: auto-detect from edited post or use query parameter
final composeType = originalPost?.type ?? type ?? 0; final composeType = originalPost?.type ?? type ?? 0;
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
final forwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// If type is 1 (article), return ArticleComposeScreen // If type is 1 (article), return ArticleComposeScreen
if (composeType == 1) { if (composeType == 1) {
@@ -136,8 +137,11 @@ class PostComposeScreen extends HookConsumerWidget {
// Initialize publisher once when data is available // Initialize publisher once when data is available
useEffect(() { useEffect(() {
if (publishers.value?.isNotEmpty ?? false) { if (publishers.value?.isNotEmpty ?? false) {
if (state.currentPublisher.value == null) {
// If no publisher is set, use the first available one
state.currentPublisher.value = publishers.value!.first; state.currentPublisher.value = publishers.value!.first;
} }
}
return null; return null;
}, [publishers]); }, [publishers]);
@@ -480,8 +484,10 @@ class PostComposeScreen extends HookConsumerWidget {
Widget _buildInfoBanner(BuildContext context) { Widget _buildInfoBanner(BuildContext context) {
// When editing, preserve the original replied/forwarded post references // When editing, preserve the original replied/forwarded post references
final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost; final effectiveRepliedPost =
final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost; initialState?.replyingTo ?? originalPost?.repliedPost;
final effectiveForwardedPost =
initialState?.forwardingTo ?? originalPost?.forwardedPost;
// Show editing banner when editing a post // Show editing banner when editing a post
if (originalPost != null) { if (originalPost != null) {
@@ -497,15 +503,15 @@ class PostComposeScreen extends HookConsumerWidget {
size: 16, size: 16,
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(context).colorScheme.onPrimaryContainer,
), ),
const Gap(4), const Gap(8),
Text( Text(
'edit'.tr(), 'postEditing'.tr(),
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(context).colorScheme.onPrimaryContainer,
), ),
), ),
], ],
).padding(all: 16), ).padding(horizontal: 16, vertical: 8),
), ),
// Show reply/forward banners below editing banner if they exist // Show reply/forward banners below editing banner if they exist
if (effectiveRepliedPost != null) if (effectiveRepliedPost != null)
@@ -615,6 +621,7 @@ class PostComposeScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: builder:
(context) => DraggableScrollableSheet( (context) => DraggableScrollableSheet(
initialChildSize: 0.7, initialChildSize: 0.7,

View File

@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$PostComposeInitialState { mixin _$PostComposeInitialState {
String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility; String? get title; String? get description; String? get content; List<UniversalFile> get attachments; int? get visibility; SnPost? get replyingTo; SnPost? get forwardingTo;
/// Create a copy of PostComposeInitialState /// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -29,16 +29,16 @@ $PostComposeInitialStateCopyWith<PostComposeInitialState> get copyWith => _$Post
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)); return identical(this, other) || (other.runtimeType == runtimeType&&other is PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other.attachments, attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)&&(identical(other.forwardingTo, forwardingTo) || other.forwardingTo == forwardingTo));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility); int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(attachments),visibility,replyingTo,forwardingTo);
@override @override
String toString() { String toString() {
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)'; return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility, replyingTo: $replyingTo, forwardingTo: $forwardingTo)';
} }
@@ -49,11 +49,11 @@ abstract mixin class $PostComposeInitialStateCopyWith<$Res> {
factory $PostComposeInitialStateCopyWith(PostComposeInitialState value, $Res Function(PostComposeInitialState) _then) = _$PostComposeInitialStateCopyWithImpl; factory $PostComposeInitialStateCopyWith(PostComposeInitialState value, $Res Function(PostComposeInitialState) _then) = _$PostComposeInitialStateCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility, SnPost? replyingTo, SnPost? forwardingTo
}); });
$SnPostCopyWith<$Res>? get replyingTo;$SnPostCopyWith<$Res>? get forwardingTo;
} }
/// @nodoc /// @nodoc
@@ -66,17 +66,43 @@ class _$PostComposeInitialStateCopyWithImpl<$Res>
/// Create a copy of PostComposeInitialState /// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,Object? replyingTo = freezed,Object? forwardingTo = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable as String?,attachments: null == attachments ? _self.attachments : attachments // ignore: cast_nullable_to_non_nullable
as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int?, as int?,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable
as SnPost?,forwardingTo: freezed == forwardingTo ? _self.forwardingTo : forwardingTo // ignore: cast_nullable_to_non_nullable
as SnPost?,
)); ));
} }
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostCopyWith<$Res>? get replyingTo {
if (_self.replyingTo == null) {
return null;
}
return $SnPostCopyWith<$Res>(_self.replyingTo!, (value) {
return _then(_self.copyWith(replyingTo: value));
});
}/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostCopyWith<$Res>? get forwardingTo {
if (_self.forwardingTo == null) {
return null;
}
return $SnPostCopyWith<$Res>(_self.forwardingTo!, (value) {
return _then(_self.copyWith(forwardingTo: value));
});
}
} }
@@ -84,7 +110,7 @@ as int?,
@JsonSerializable() @JsonSerializable()
class _PostComposeInitialState implements PostComposeInitialState { class _PostComposeInitialState implements PostComposeInitialState {
const _PostComposeInitialState({this.title, this.description, this.content, final List<UniversalFile> attachments = const [], this.visibility}): _attachments = attachments; const _PostComposeInitialState({this.title, this.description, this.content, final List<UniversalFile> attachments = const [], this.visibility, this.replyingTo, this.forwardingTo}): _attachments = attachments;
factory _PostComposeInitialState.fromJson(Map<String, dynamic> json) => _$PostComposeInitialStateFromJson(json); factory _PostComposeInitialState.fromJson(Map<String, dynamic> json) => _$PostComposeInitialStateFromJson(json);
@override final String? title; @override final String? title;
@@ -98,6 +124,8 @@ class _PostComposeInitialState implements PostComposeInitialState {
} }
@override final int? visibility; @override final int? visibility;
@override final SnPost? replyingTo;
@override final SnPost? forwardingTo;
/// Create a copy of PostComposeInitialState /// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -112,16 +140,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostComposeInitialState&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.content, content) || other.content == content)&&const DeepCollectionEquality().equals(other._attachments, _attachments)&&(identical(other.visibility, visibility) || other.visibility == visibility)&&(identical(other.replyingTo, replyingTo) || other.replyingTo == replyingTo)&&(identical(other.forwardingTo, forwardingTo) || other.forwardingTo == forwardingTo));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility); int get hashCode => Object.hash(runtimeType,title,description,content,const DeepCollectionEquality().hash(_attachments),visibility,replyingTo,forwardingTo);
@override @override
String toString() { String toString() {
return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility)'; return 'PostComposeInitialState(title: $title, description: $description, content: $content, attachments: $attachments, visibility: $visibility, replyingTo: $replyingTo, forwardingTo: $forwardingTo)';
} }
@@ -132,11 +160,11 @@ abstract mixin class _$PostComposeInitialStateCopyWith<$Res> implements $PostCom
factory _$PostComposeInitialStateCopyWith(_PostComposeInitialState value, $Res Function(_PostComposeInitialState) _then) = __$PostComposeInitialStateCopyWithImpl; factory _$PostComposeInitialStateCopyWith(_PostComposeInitialState value, $Res Function(_PostComposeInitialState) _then) = __$PostComposeInitialStateCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility String? title, String? description, String? content, List<UniversalFile> attachments, int? visibility, SnPost? replyingTo, SnPost? forwardingTo
}); });
@override $SnPostCopyWith<$Res>? get replyingTo;@override $SnPostCopyWith<$Res>? get forwardingTo;
} }
/// @nodoc /// @nodoc
@@ -149,18 +177,44 @@ class __$PostComposeInitialStateCopyWithImpl<$Res>
/// Create a copy of PostComposeInitialState /// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? title = freezed,Object? description = freezed,Object? content = freezed,Object? attachments = null,Object? visibility = freezed,Object? replyingTo = freezed,Object? forwardingTo = freezed,}) {
return _then(_PostComposeInitialState( return _then(_PostComposeInitialState(
title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable title: freezed == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable as String?,content: freezed == content ? _self.content : content // ignore: cast_nullable_to_non_nullable
as String?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable as String?,attachments: null == attachments ? _self._attachments : attachments // ignore: cast_nullable_to_non_nullable
as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable as List<UniversalFile>,visibility: freezed == visibility ? _self.visibility : visibility // ignore: cast_nullable_to_non_nullable
as int?, as int?,replyingTo: freezed == replyingTo ? _self.replyingTo : replyingTo // ignore: cast_nullable_to_non_nullable
as SnPost?,forwardingTo: freezed == forwardingTo ? _self.forwardingTo : forwardingTo // ignore: cast_nullable_to_non_nullable
as SnPost?,
)); ));
} }
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostCopyWith<$Res>? get replyingTo {
if (_self.replyingTo == null) {
return null;
}
return $SnPostCopyWith<$Res>(_self.replyingTo!, (value) {
return _then(_self.copyWith(replyingTo: value));
});
}/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SnPostCopyWith<$Res>? get forwardingTo {
if (_self.forwardingTo == null) {
return null;
}
return $SnPostCopyWith<$Res>(_self.forwardingTo!, (value) {
return _then(_self.copyWith(forwardingTo: value));
});
}
} }
// dart format on // dart format on

View File

@@ -18,6 +18,14 @@ _PostComposeInitialState _$PostComposeInitialStateFromJson(
.toList() ?? .toList() ??
const [], const [],
visibility: (json['visibility'] as num?)?.toInt(), visibility: (json['visibility'] as num?)?.toInt(),
replyingTo:
json['replying_to'] == null
? null
: SnPost.fromJson(json['replying_to'] as Map<String, dynamic>),
forwardingTo:
json['forwarding_to'] == null
? null
: SnPost.fromJson(json['forwarding_to'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$PostComposeInitialStateToJson( Map<String, dynamic> _$PostComposeInitialStateToJson(
@@ -28,4 +36,6 @@ Map<String, dynamic> _$PostComposeInitialStateToJson(
'content': instance.content, 'content': instance.content,
'attachments': instance.attachments.map((e) => e.toJson()).toList(), 'attachments': instance.attachments.map((e) => e.toJson()).toList(),
'visibility': instance.visibility, 'visibility': instance.visibility,
'replying_to': instance.replyingTo?.toJson(),
'forwarding_to': instance.forwardingTo?.toJson(),
}; };

View File

@@ -238,7 +238,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
children: [ children: [
// Publisher row // Publisher row
Card( Card(
margin: EdgeInsets.only(bottom: 8), margin: EdgeInsets.only(top: 8),
elevation: 1, elevation: 1,
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -265,11 +265,21 @@ class ArticleComposeScreen extends HookConsumerWidget {
}); });
}, },
), ),
const Gap(12), const Gap(16),
if (state.currentPublisher.value == null)
Text( Text(
state.currentPublisher.value?.name ??
'postPublisherUnselected'.tr(), 'postPublisherUnselected'.tr(),
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(state.currentPublisher.value!.nick).bold(),
Text(
'@${state.currentPublisher.value!.name}',
).fontSize(12),
],
), ),
], ],
), ),
@@ -311,8 +321,15 @@ class ArticleComposeScreen extends HookConsumerWidget {
builder: (context, attachments, _) { builder: (context, attachments, _) {
if (attachments.isEmpty) return const SizedBox.shrink(); if (attachments.isEmpty) return const SizedBox.shrink();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Gap(16), const Gap(16),
Text(
'articleAttachmentHint'.tr(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
).padding(bottom: 8),
ValueListenableBuilder<Map<int, double>>( ValueListenableBuilder<Map<int, double>>(
valueListenable: state.attachmentProgress, valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) { builder: (context, progressMap, _) {
@@ -322,8 +339,8 @@ class ArticleComposeScreen extends HookConsumerWidget {
children: [ children: [
for (var idx = 0; idx < attachments.length; idx++) for (var idx = 0; idx < attachments.length; idx++)
SizedBox( SizedBox(
width: 120, width: 280,
height: 120, height: 280,
child: AttachmentPreview( child: AttachmentPreview(
item: attachments[idx], item: attachments[idx],
progress: progressMap[idx], progress: progressMap[idx],
@@ -348,6 +365,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
delta, delta,
); );
}, },
onInsert:
() => ComposeLogic.insertAttachment(
ref,
state,
idx,
),
), ),
), ),
], ],

View File

@@ -21,9 +21,10 @@ Future<SnPost?> post(Ref ref, String id) async {
return SnPost.fromJson(resp.data); return SnPost.fromJson(resp.data);
} }
final postStateProvider = StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>( final postStateProvider =
StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>(
(ref, id) => PostState(ref, id), (ref, id) => PostState(ref, id),
); );
class PostState extends StateNotifier<AsyncValue<SnPost?>> { class PostState extends StateNotifier<AsyncValue<SnPost?>> {
final Ref _ref; final Ref _ref;
@@ -74,7 +75,9 @@ class PostDetailScreen extends HookConsumerWidget {
backgroundColor: isWide ? Colors.transparent : null, backgroundColor: isWide ? Colors.transparent : null,
onUpdate: (newItem) { onUpdate: (newItem) {
// Update the local state with the new post data // Update the local state with the new post data
ref.read(postStateProvider(id).notifier).updatePost(newItem); ref
.read(postStateProvider(id).notifier)
.updatePost(newItem);
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
@@ -92,16 +95,21 @@ class PostDetailScreen extends HookConsumerWidget {
right: 0, right: 0,
child: Material( child: Material(
elevation: 2, elevation: 2,
child: postState.when( child: postState
data: (post) => PostQuickReply( .when(
data:
(post) => PostQuickReply(
parent: post!, parent: post!,
onPosted: () { onPosted: () {
ref.invalidate(postRepliesNotifierProvider(id)); ref.invalidate(
postRepliesNotifierProvider(id),
);
}, },
), ),
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
).padding( )
.padding(
bottom: MediaQuery.of(context).padding.bottom + 16, bottom: MediaQuery.of(context).padding.bottom + 16,
top: 16, top: 16,
horizontal: 16, horizontal: 16,

View File

@@ -63,7 +63,10 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
}); });
} }
Future<void> subscribePushNotification(Dio apiClient) async { Future<void> subscribePushNotification(
Dio apiClient, {
bool detailedErrors = false,
}) async {
await FirebaseMessaging.instance.requestPermission( await FirebaseMessaging.instance.requestPermission(
provisional: true, provisional: true,
alert: true, alert: true,
@@ -97,6 +100,8 @@ Future<void> subscribePushNotification(Dio apiClient) async {
deviceToken, deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
); );
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
} }
} }

View File

@@ -59,7 +59,7 @@ class AccountStatusCreationSheet extends HookConsumerWidget {
}, },
options: Options(method: initialStatus == null ? 'POST' : 'PATCH'), options: Options(method: initialStatus == null ? 'POST' : 'PATCH'),
); );
if (user.hasValue) { if (user.value != null) {
ref.invalidate(accountStatusProvider(user.value!.name)); ref.invalidate(accountStatusProvider(user.value!.name));
} }
if (!context.mounted) return; if (!context.mounted) return;

View File

@@ -350,7 +350,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
return AnimatedPositioned( return AnimatedPositioned(
duration: Duration(milliseconds: 1850), duration: Duration(milliseconds: 1850),
top: top:
!user.hasValue || user.value == null ||
user.value == null || user.value == null ||
websocketState == WebSocketState.connected() websocketState == WebSocketState.connected()
? -indicatorHeight ? -indicatorHeight
@@ -362,7 +362,7 @@ class _WebSocketIndicator extends HookConsumerWidget {
child: IgnorePointer( child: IgnorePointer(
child: Material( child: Material(
elevation: elevation:
!user.hasValue || websocketState == WebSocketState.connected() user.value == null || websocketState == WebSocketState.connected()
? 0 ? 0
: 4, : 4,
child: AnimatedContainer( child: AnimatedContainer(

View File

@@ -15,6 +15,7 @@ class AttachmentPreview extends StatelessWidget {
final double? progress; final double? progress;
final Function(int)? onMove; final Function(int)? onMove;
final Function? onDelete; final Function? onDelete;
final Function? onInsert;
final Function? onRequestUpload; final Function? onRequestUpload;
const AttachmentPreview({ const AttachmentPreview({
super.key, super.key,
@@ -23,6 +24,7 @@ class AttachmentPreview extends StatelessWidget {
this.onRequestUpload, this.onRequestUpload,
this.onMove, this.onMove,
this.onDelete, this.onDelete,
this.onInsert,
}); });
@override @override
@@ -104,7 +106,11 @@ class AttachmentPreview extends StatelessWidget {
style: TextStyle(color: Colors.white), style: TextStyle(color: Colors.white),
), ),
Gap(6), Gap(6),
Center(child: LinearProgressIndicator(value: progress)), Center(
child: LinearProgressIndicator(
value: progress != null ? progress! / 100.0 : null,
),
),
], ],
), ),
), ),
@@ -166,6 +172,18 @@ class AttachmentPreview extends StatelessWidget {
onMove?.call(1); onMove?.call(1);
}, },
), ),
if (onInsert != null)
InkWell(
borderRadius: BorderRadius.circular(8),
child: const Icon(
Symbols.add,
size: 14,
color: Colors.white,
).padding(horizontal: 8, vertical: 6),
onTap: () {
onInsert?.call();
},
),
], ],
), ),
), ),

View File

@@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -6,11 +7,14 @@ import 'package:flutter_highlight/themes/a11y-dark.dart';
import 'package:flutter_highlight/themes/a11y-light.dart'; import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown_latex.dart'; import 'package:island/widgets/content/markdown_latex.dart';
import 'package:markdown/markdown.dart' as markdown; import 'package:markdown/markdown.dart' as markdown;
import 'package:markdown_widget/markdown_widget.dart'; import 'package:markdown_widget/markdown_widget.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'image.dart'; import 'image.dart';
@@ -23,6 +27,7 @@ class MarkdownTextContent extends HookConsumerWidget {
final TextStyle? linkStyle; final TextStyle? linkStyle;
final EdgeInsets? linesMargin; final EdgeInsets? linesMargin;
final bool isSelectable; final bool isSelectable;
final List<SnCloudFile>? attachments;
const MarkdownTextContent({ const MarkdownTextContent({
super.key, super.key,
@@ -33,6 +38,7 @@ class MarkdownTextContent extends HookConsumerWidget {
this.linkStyle, this.linkStyle,
this.isSelectable = false, this.isSelectable = false,
this.linesMargin, this.linesMargin,
this.attachments,
}); });
@override @override
@@ -109,6 +115,29 @@ class MarkdownTextContent extends HookConsumerWidget {
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (uri.scheme == 'solian') { if (uri.scheme == 'solian') {
switch (uri.host) { switch (uri.host) {
case 'files':
final file = attachments?.firstWhereOrNull(
(file) => file.id == uri.pathSegments[0],
);
if (file == null) {
return const SizedBox.shrink();
}
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudFileWidget(
item: file,
fit: BoxFit.cover,
).clipRRect(all: 8),
),
);
case 'stickers': case 'stickers':
final size = doesEnlargeSticker ? 96.0 : 24.0; final size = doesEnlargeSticker ? 96.0 : 24.0;
return ClipRRect( return ClipRRect(
@@ -132,9 +161,9 @@ class MarkdownTextContent extends HookConsumerWidget {
); );
} }
} }
final content = UniversalImage( final content = ConstrainedBox(
uri: uri.toString(), constraints: BoxConstraints(maxHeight: 360),
fit: BoxFit.cover, child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain),
); );
return content; return content;
}, },

View File

@@ -98,19 +98,11 @@ class ComposeLogic {
descriptionController: TextEditingController( descriptionController: TextEditingController(
text: originalPost?.description, text: originalPost?.description,
), ),
contentController: TextEditingController( contentController: TextEditingController(text: originalPost?.content),
text:
originalPost?.content ??
(forwardedPost != null
? '''> ${forwardedPost.content}
'''
: null),
),
visibility: ValueNotifier<int>(originalPost?.visibility ?? 0), visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
submitting: ValueNotifier<bool>(false), submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}), attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null), currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
tagsController: tagsController, tagsController: tagsController,
categoriesController: categoriesController, categoriesController: categoriesController,
draftId: id, draftId: id,
@@ -482,6 +474,23 @@ class ComposeLogic {
state.attachments.value = clone; state.attachments.value = clone;
} }
static void insertAttachment(WidgetRef ref, ComposeState state, int index) {
final attachment = state.attachments.value[index];
if (!attachment.isOnCloud) {
return;
}
final cloudFile = attachment.data as SnCloudFile;
final markdown = '![${cloudFile.name}](solian://files/${cloudFile.id})';
final controller = state.contentController;
final text = controller.text;
final selection = controller.selection;
final newText = text.replaceRange(selection.start, selection.end, markdown);
controller.text = newText;
controller.selection = TextSelection.fromPosition(
TextPosition(offset: selection.start + markdown.length),
);
}
static Future<void> performAction( static Future<void> performAction(
WidgetRef ref, WidgetRef ref,
ComposeState state, ComposeState state,

View File

@@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:island/widgets/account/account_name.dart'; import 'package:island/widgets/account/account_name.dart';
@@ -55,106 +56,192 @@ class PostItem extends HookConsumerWidget {
final user = ref.watch(userInfoProvider); final user = ref.watch(userInfoProvider);
final isAuthor = useMemoized( final isAuthor = useMemoized(
() => user.hasValue && user.value?.id == item.publisher.accountId, () => user.value != null && user.value?.id == item.publisher.accountId,
[user], [user],
); );
final hasBackground = final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null; ref.watch(backgroundImageFileProvider).valueOrNull != null;
return ContextMenuWidget( Widget child;
menuProvider: (_) { if (item.type == 1 && isFullPost) {
return Menu( child = Padding(
padding: renderingPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (isAuthor) GestureDetector(
MenuAction( onTap: () {
title: 'edit'.tr(), context.push('/publishers/${item.publisher.name}');
image: MenuImage.icon(Symbols.edit),
callback: () {
context.push('/posts/${item.id}/edit').then((value) {
if (value != null) {
onRefresh?.call();
}
});
}, },
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ProfilePictureWidget(file: item.publisher.picture),
const Gap(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(
mark: item.publisher.verification!,
).padding(left: 4),
],
), ),
if (isAuthor)
MenuAction(
title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete),
callback: () {
showConfirmAlert(
'deletePostHint'.tr(),
'deletePost'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client
.delete('/posts/${item.id}')
.catchError((err) {
showErrorAlert(err);
return err;
})
.then((_) {
onRefresh?.call();
});
}
});
},
), ),
if (isAuthor) MenuSeparator(), Text(
MenuAction( isFullPost
title: 'copyLink'.tr(), ? item.publishedAt?.formatSystem() ?? ''
image: MenuImage.icon(Symbols.link), : item.publishedAt?.formatRelative(context) ?? '',
callback: () { ).fontSize(11),
Clipboard.setData( ],
ClipboardData(text: 'https://solsynth.dev/posts/${item.id}'),
);
},
), ),
MenuAction(
title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply),
callback: () {
context.push('/posts/compose', extra: {'repliedPost': item});
},
), ),
MenuAction( if (item.visibility != 0)
title: 'forward'.tr(), Row(
image: MenuImage.icon(Symbols.forward), mainAxisSize: MainAxisSize.min,
callback: () { children: [
context.push('/posts/compose', extra: {'forwardedPost': item}); Icon(
}, _getVisibilityIcon(item.visibility),
size: 14,
color: Theme.of(context).colorScheme.secondary,
), ),
MenuSeparator(), const SizedBox(width: 4),
MenuAction( Text(
title: 'share'.tr(), _getVisibilityText(item.visibility).tr(),
image: MenuImage.icon(Symbols.share), style: TextStyle(
callback: () { fontSize: 12,
showShareSheetLink( color: Theme.of(context).colorScheme.secondary,
context: context,
link: '${ref.read(serverUrlProvider)}/posts/${item.id}',
title: 'sharePost'.tr(),
toSystem: true,
);
},
), ),
MenuAction(
title: 'abuseReport'.tr(),
image: MenuImage.icon(Symbols.flag),
callback: () {
showAbuseReportSheet(
context,
resourceIdentifier: 'posts:${item.id}',
);
},
), ),
], ],
).padding(top: 10, bottom: 2),
const Gap(16),
_ArticlePostDisplay(item: item, isFullPost: isFullPost),
if (item.tags.isNotEmpty || item.categories.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.tags.isNotEmpty)
Wrap(
children: [
for (final tag in item.tags)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.label, size: 13),
Text(tag.name ?? '#${tag.slug}').fontSize(13),
],
),
onTap: () {},
),
],
),
if (item.categories.isNotEmpty)
Wrap(
children: [
for (final category in item.categories)
InkWell(
child: Row(
spacing: 4,
children: [
const Icon(Symbols.category, size: 13),
Text(
category.name ?? '#${category.slug}',
).fontSize(13),
],
),
onTap: () {},
),
],
),
],
),
if ((item.repliedPost != null || item.forwardedPost != null) &&
showReferencePost)
_buildReferencePost(context, item),
if (item.attachments.isNotEmpty && item.type != 1)
CloudFileList(
files: item.attachments,
maxWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
minWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
),
if (item.meta?['embeds'] != null)
...((item.meta!['embeds'] as List<dynamic>)
.where((embed) => embed['Type'] == 'link')
.map(
(embedData) => EmbedLinkWidget(
link: SnEmbedLink.fromJson(
embedData as Map<String, dynamic>,
),
maxWidth: math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
margin: EdgeInsets.only(top: 8),
),
)),
const Gap(8),
Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12),
child: ActionChip(
avatar: Icon(Symbols.reply, size: 16),
label: Text(
(item.repliesCount > 0)
? 'repliesCount'.plural(item.repliesCount)
: 'reply'.tr(),
),
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
onPressed: () {
if (isOpenable) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => PostRepliesSheet(post: item),
);
}
},
),
),
Expanded(
child: PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.zero,
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(
item.copyWith(reactionsCount: reactionsCount),
); );
}, },
child: Material( ),
color: hasBackground ? Colors.transparent : backgroundColor, ),
child: Padding( ],
),
],
),
);
} else {
child = Padding(
padding: renderingPadding, padding: renderingPadding,
child: Column( child: Column(
spacing: 8, spacing: 8,
@@ -186,9 +273,7 @@ class PostItem extends HookConsumerWidget {
Text( Text(
isFullPost isFullPost
? item.publishedAt?.formatSystem() ?? '' ? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative( : item.publishedAt?.formatRelative(context) ??
context,
) ??
'', '',
).fontSize(11).alignment(Alignment.bottomRight), ).fontSize(11).alignment(Alignment.bottomRight),
const Gap(4), const Gap(4),
@@ -202,8 +287,7 @@ class PostItem extends HookConsumerWidget {
Icon( Icon(
_getVisibilityIcon(item.visibility), _getVisibilityIcon(item.visibility),
size: 14, size: 14,
color: color: Theme.of(context).colorScheme.secondary,
Theme.of(context).colorScheme.secondary,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -216,6 +300,12 @@ class PostItem extends HookConsumerWidget {
), ),
], ],
).padding(top: 2, bottom: 2), ).padding(top: 2, bottom: 2),
if (item.type == 1)
_ArticlePostDisplay(
item: item,
isFullPost: isFullPost,
)
else ...[
if (item.title?.isNotEmpty ?? false) if (item.title?.isNotEmpty ?? false)
Text( Text(
item.title!, item.title!,
@@ -241,10 +331,11 @@ class PostItem extends HookConsumerWidget {
item.type == 0 item.type == 0
? EdgeInsets.only(bottom: 8) ? EdgeInsets.only(bottom: 8)
: null, : null,
attachments: item.attachments,
), ),
],
// Render tags and categories if they exist // Render tags and categories if they exist
if (item.tags.isNotEmpty || if (item.tags.isNotEmpty || item.categories.isNotEmpty)
item.categories.isNotEmpty)
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -256,10 +347,7 @@ class PostItem extends HookConsumerWidget {
child: Row( child: Row(
spacing: 4, spacing: 4,
children: [ children: [
const Icon( const Icon(Symbols.label, size: 13),
Symbols.label,
size: 13,
),
Text( Text(
tag.name ?? '#${tag.slug}', tag.name ?? '#${tag.slug}',
).fontSize(13), ).fontSize(13),
@@ -294,7 +382,7 @@ class PostItem extends HookConsumerWidget {
], ],
), ),
// Show truncation hint if post is truncated // Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost) if (item.isTruncated && !isFullPost && item.type != 1)
_PostTruncateHint().padding( _PostTruncateHint().padding(
bottom: item.attachments.isNotEmpty ? 8 : null, bottom: item.attachments.isNotEmpty ? 8 : null,
), ),
@@ -302,7 +390,7 @@ class PostItem extends HookConsumerWidget {
item.forwardedPost != null) && item.forwardedPost != null) &&
showReferencePost) showReferencePost)
_buildReferencePost(context, item), _buildReferencePost(context, item),
if (item.attachments.isNotEmpty) if (item.attachments.isNotEmpty && item.type != 1)
CloudFileList( CloudFileList(
files: item.attachments, files: item.attachments,
maxWidth: math.min( maxWidth: math.min(
@@ -391,7 +479,108 @@ class PostItem extends HookConsumerWidget {
), ),
], ],
), ),
);
}
return ContextMenuWidget(
menuProvider: (_) {
return Menu(
children: [
if (isAuthor)
MenuAction(
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
context.push('/posts/${item.id}/edit').then((value) {
if (value != null) {
onRefresh?.call();
}
});
},
), ),
if (isAuthor)
MenuAction(
title: 'delete'.tr(),
image: MenuImage.icon(Symbols.delete),
callback: () {
showConfirmAlert(
'deletePostHint'.tr(),
'deletePost'.tr(),
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client
.delete('/posts/${item.id}')
.catchError((err) {
showErrorAlert(err);
return err;
})
.then((_) {
onRefresh?.call();
});
}
});
},
),
if (isAuthor) MenuSeparator(),
MenuAction(
title: 'copyLink'.tr(),
image: MenuImage.icon(Symbols.link),
callback: () {
Clipboard.setData(
ClipboardData(text: 'https://solsynth.dev/posts/${item.id}'),
);
},
),
MenuAction(
title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply),
callback: () {
context.push(
'/posts/compose',
extra: PostComposeInitialState(replyingTo: item),
);
},
),
MenuAction(
title: 'forward'.tr(),
image: MenuImage.icon(Symbols.forward),
callback: () {
context.push(
'/posts/compose',
extra: PostComposeInitialState(forwardingTo: item),
);
},
),
MenuSeparator(),
MenuAction(
title: 'share'.tr(),
image: MenuImage.icon(Symbols.share),
callback: () {
showShareSheetLink(
context: context,
link: '${ref.read(serverUrlProvider)}/posts/${item.id}',
title: 'sharePost'.tr(),
toSystem: true,
);
},
),
MenuAction(
title: 'abuseReport'.tr(),
image: MenuImage.icon(Symbols.flag),
callback: () {
showAbuseReportSheet(
context,
resourceIdentifier: 'posts:${item.id}',
);
},
),
],
);
},
child: Material(
color: hasBackground ? Colors.transparent : backgroundColor,
child: child,
), ),
); );
} }
@@ -501,6 +690,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
referencePost.type == 0 referencePost.type == 0
? EdgeInsets.only(bottom: 4) ? EdgeInsets.only(bottom: 4)
: null, : null,
attachments: item.attachments,
).padding(bottom: 4), ).padding(bottom: 4),
// Truncation hint for referenced post // Truncation hint for referenced post
if (referencePost.isTruncated) if (referencePost.isTruncated)
@@ -508,7 +698,8 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
isCompact: true, isCompact: true,
margin: const EdgeInsets.only(top: 4, bottom: 8), margin: const EdgeInsets.only(top: 4, bottom: 8),
), ),
if (referencePost.attachments.isNotEmpty) if (referencePost.attachments.isNotEmpty &&
referencePost.type != 1)
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -805,6 +996,129 @@ class _PostTruncateHint extends StatelessWidget {
} }
} }
class _ArticlePostDisplay extends StatelessWidget {
final SnPost item;
final bool isFullPost;
const _ArticlePostDisplay({required this.item, required this.isFullPost});
@override
Widget build(BuildContext context) {
if (isFullPost) {
// Full article view
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.title?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
item.title!,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (item.description?.isNotEmpty ?? false)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
item.description!,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (item.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: item.content!,
textStyle: Theme.of(context).textTheme.bodyLarge,
attachments: item.attachments,
),
],
);
} else {
// Truncated/Card view
String? previewContent;
if (item.description?.isNotEmpty ?? false) {
previewContent = item.description!;
} else if (item.content?.isNotEmpty ?? false) {
previewContent = item.content!;
}
return Card(
elevation: 0,
margin: const EdgeInsets.only(top: 4),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (item.title?.isNotEmpty ?? false)
Text(
item.title!,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (previewContent != null) ...[
const Gap(8),
Text(
previewContent,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Symbols.article,
size: 16,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 6),
Text(
'postArticle'.tr(),
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontSize: 12,
),
),
const SizedBox(width: 4),
],
),
),
],
),
),
);
}
}
}
// Helper method to get the appropriate icon for each visibility status // Helper method to get the appropriate icon for each visibility status
IconData _getVisibilityIcon(int visibility) { IconData _getVisibilityIcon(int visibility) {
switch (visibility) { switch (visibility) {

View File

@@ -87,7 +87,7 @@ class PostItemCreator extends HookConsumerWidget {
); );
}, },
child: Material( child: Material(
color: Colors.transparent, color: backgroundColor ?? Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
elevation: 1, elevation: 1,
child: InkWell( child: InkWell(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/post_replies.dart'; import 'package:island/widgets/post/post_replies.dart';
import 'package:island/widgets/post/post_quick_reply.dart'; import 'package:island/widgets/post/post_quick_reply.dart';
@@ -14,6 +15,8 @@ class PostRepliesSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(userInfoProvider);
return SheetScaffold( return SheetScaffold(
titleText: 'repliesCount'.plural(post.repliesCount), titleText: 'repliesCount'.plural(post.repliesCount),
child: Column( child: Column(
@@ -21,13 +24,16 @@ class PostRepliesSheet extends HookConsumerWidget {
// Replies list // Replies list
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [PostRepliesList( slivers: [
PostRepliesList(
postId: post.id.toString(), postId: post.id.toString(),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
)], ),
],
), ),
), ),
// Quick reply section // Quick reply section
if (user.value != null)
Material( Material(
elevation: 2, elevation: 2,
child: PostQuickReply( child: PostQuickReply(

View File

@@ -1025,7 +1025,7 @@ packages:
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
@@ -1097,10 +1097,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: ac294be30ba841830cfa146e5a3b22bb09f8dc5a0fdd9ca9332b04b0bde99ebf sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.2.4" version: "16.0.0"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 3.0.0+110 version: 3.0.0+111
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2
@@ -30,6 +30,8 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_web_plugins:
sdk: flutter
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
@@ -37,7 +39,7 @@ dependencies:
flutter_hooks: ^0.21.2 flutter_hooks: ^0.21.2
hooks_riverpod: ^2.6.1 hooks_riverpod: ^2.6.1
bitsdojo_window: ^0.1.6 bitsdojo_window: ^0.1.6
go_router: ^15.1.3 go_router: ^16.0.0
styled_widget: ^0.4.1 styled_widget: ^0.4.1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1