Compare commits

...

16 Commits

Author SHA1 Message Date
552b4b2572 🚀 Launch 3.0.0+111 2025-07-03 01:20:54 +08:00
594ac39e3d Article attachments 2025-07-03 01:18:07 +08:00
23321171f3 💄 Optimized attachment insert in article compose 2025-07-03 01:11:56 +08:00
ee72d79c93 Hide attachments list on article 2025-07-03 01:06:01 +08:00
a20c2598fc 🐛 Fixes render errors of unauthorized 2025-07-03 00:49:11 +08:00
2eba871a6d 💄 Web article has max width 2025-07-03 00:39:45 +08:00
46919dec31 📝 Updated about screen 2025-07-03 00:34:11 +08:00
9dd6cffe0c 🐛 Trying to fix push notification 2025-07-03 00:27:30 +08:00
2ea9f5e907 💄 Optimized rendering for article post 2025-07-03 00:16:10 +08:00
050750a808 🐛 Fixes articles bugs 2025-07-02 23:57:07 +08:00
f479b9fc8b 🐛 Bug fixes on posts writing and etc 2025-07-02 23:34:27 +08:00
13ea182707 💄 Localized about page 2025-07-02 22:59:28 +08:00
14183a7316 💄 Colorful name for subscribed users 2025-07-02 22:24:56 +08:00
9fc9b87608 💄 Optimized leveling page 2025-07-02 22:17:25 +08:00
53c2445ba9 🐛 Remove extra items inside settings 2025-07-02 21:47:26 +08:00
d414695eb3 🍱 Update English translation 2025-07-02 21:44:54 +08:00
36 changed files with 2469 additions and 1662 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -18,8 +18,13 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final user = SnAccount.fromJson(response.data);
state = AsyncValue.data(user);
} catch (error, stackTrace) {
log("[UserInfo] Failed to fetch user info: $error");
state = AsyncValue.error(error, stackTrace);
log(
"[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:
(context, state) => PostComposeScreen(
initialState: state.extra as PostComposeInitialState?,
type:
int.tryParse(state.uri.queryParameters['type'] ?? '0') ??
0,
),
),
GoRoute(

View File

@ -1,22 +1,34 @@
import 'package:device_info_plus/device_info_plus.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/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:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:easy_localization/easy_localization.dart';
class AboutScreen extends StatefulWidget {
class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key});
@override
State<AboutScreen> createState() => _AboutScreenState();
ConsumerState<AboutScreen> createState() => _AboutScreenState();
}
class _AboutScreenState extends State<AboutScreen> {
class _AboutScreenState extends ConsumerState<AboutScreen> {
PackageInfo _packageInfo = PackageInfo(
appName: 'Island',
packageName: 'com.example.island',
appName: 'Solian',
packageName: 'dev.solsynth.solian',
version: '1.0.0',
buildNumber: '1',
);
BaseDeviceInfo? _deviceInfo;
String? _deviceUdid;
bool _isLoading = true;
String? _errorMessage;
@ -24,6 +36,7 @@ class _AboutScreenState extends State<AboutScreen> {
void initState() {
super.initState();
_initPackageInfo();
_initDeviceInfo();
}
Future<void> _initPackageInfo() async {
@ -38,13 +51,34 @@ class _AboutScreenState extends State<AboutScreen> {
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = 'Failed to load package info: $e';
_errorMessage = 'aboutScreenFailedToLoadPackageInfo'.tr(
args: [e.toString()],
);
_isLoading = false;
});
}
}
}
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 {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
@ -57,7 +91,7 @@ class _AboutScreenState extends State<AboutScreen> {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: const Text('About'), elevation: 0),
appBar: AppBar(title: Text('about'.tr()), elevation: 0),
body:
_isLoading
? const Center(child: CircularProgressIndicator())
@ -88,7 +122,9 @@ class _AboutScreenState extends State<AboutScreen> {
),
),
Text(
'Version ${_packageInfo.version} (${_packageInfo.buildNumber})',
'aboutScreenVersionInfo'.tr(
args: [_packageInfo.version, _packageInfo.buildNumber],
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
@ -98,40 +134,81 @@ class _AboutScreenState extends State<AboutScreen> {
// App Info Card
_buildSection(
context,
title: 'App Information',
title: 'aboutScreenAppInfoSectionTitle'.tr(),
children: [
_buildInfoItem(
context,
icon: Icons.info_outline,
label: 'Package Name',
icon: Symbols.info,
label: 'aboutScreenPackageNameLabel'.tr(),
value: _packageInfo.packageName,
),
_buildInfoItem(
context,
icon: Icons.update,
label: 'Version',
icon: Symbols.update,
label: 'aboutScreenVersionLabel'.tr(),
value: _packageInfo.version,
),
_buildInfoItem(
context,
icon: Icons.build,
label: 'Build Number',
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: [
_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),
// Links Card
_buildSection(
context,
title: 'Links',
title: 'aboutScreenLinksSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Icons.privacy_tip_outlined,
title: 'Privacy Policy',
icon: Symbols.privacy_tip,
title: 'aboutScreenPrivacyPolicyTitle'.tr(),
onTap:
() => _launchURL(
'https://solsynth.dev/terms/privacy-policy',
@ -139,17 +216,17 @@ class _AboutScreenState extends State<AboutScreen> {
),
_buildListTile(
context,
icon: Icons.description_outlined,
title: 'Terms of Service',
icon: Symbols.description,
title: 'aboutScreenTermsOfServiceTitle'.tr(),
onTap:
() => _launchURL(
'https://example.com/terms/basic-law',
'https://solsynth.dev/terms/basic-law',
),
),
_buildListTile(
context,
icon: Icons.code,
title: 'Open Source Licenses',
icon: Symbols.code,
title: 'aboutScreenOpenSourceLicensesTitle'.tr(),
onTap: () {
showLicensePage(
context: context,
@ -167,21 +244,22 @@ class _AboutScreenState extends State<AboutScreen> {
// Developer Info
_buildSection(
context,
title: 'Developer',
title: 'aboutScreenDeveloperSectionTitle'.tr(),
children: [
_buildListTile(
context,
icon: Icons.email_outlined,
title: 'Contact Us',
icon: Symbols.email,
title: 'aboutScreenContactUsTitle'.tr(),
subtitle: 'lily@solsynth.dev',
onTap: () => _launchURL('mailto:lily@solsynth.dev'),
),
_buildListTile(
context,
icon: Icons.copyright,
title: 'License',
subtitle:
'Copyright reserved © ${DateTime.now().year} Solsynth\nGNU Affero General Public License v3.0',
icon: Symbols.copyright,
title: 'aboutScreenLicenseTitle'.tr(),
subtitle: 'aboutScreenLicenseContent'.tr(
args: [DateTime.now().year.toString()],
),
onTap:
() => _launchURL(
'https://github.com/Solsynth/Solian/blob/v3/LICENSE.txt',
@ -195,12 +273,25 @@ class _AboutScreenState extends State<AboutScreen> {
// Copyright
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'© ${DateTime.now().year} ${_packageInfo.appName}. All rights reserved.',
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
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),
],
),
),
@ -238,6 +329,7 @@ class _AboutScreenState extends State<AboutScreen> {
required IconData icon,
required String label,
required String value,
bool copyable = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@ -254,22 +346,23 @@ class _AboutScreenState extends State<AboutScreen> {
SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: copyable ? 1 : null,
),
],
),
),
if (value.startsWith('http') || value.contains('@'))
if (value.startsWith('http') || value.contains('@') || copyable)
IconButton(
icon: const Icon(Icons.copy, size: 16),
icon: const Icon(Symbols.content_copy, size: 16),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied to clipboard')),
SnackBar(content: Text('copiedToClipboard'.tr())),
);
},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Copy to clipboard',
tooltip: 'copyToClipboardTooltip'.tr(),
),
],
),
@ -283,13 +376,18 @@ class _AboutScreenState extends State<AboutScreen> {
String? subtitle,
required VoidCallback onTap,
}) {
final multipleLines = subtitle?.contains('\n') ?? false;
return Column(
children: [
ListTile(
leading: Icon(icon),
leading: Icon(icon).padding(top: multipleLines ? 8 : 0),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle) : null,
trailing: const Icon(Icons.chevron_right),
isThreeLine: multipleLines,
trailing: const Icon(
Symbols.chevron_right,
).padding(top: multipleLines ? 8 : 0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
minLeadingWidth: 24,

View File

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

View File

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

View File

@ -1,4 +1,7 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:google_fonts/google_fonts.dart';
@ -14,7 +17,9 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
part 'leveling.g.dart';
@ -84,35 +89,6 @@ class LevelingScreen extends HookConsumerWidget {
// Membership section
_buildMembershipSection(context, ref, stellarSubscription),
const Gap(16),
// Unlocked features section
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'unlockedFeatures'.tr(),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Gap(8),
Text(
'unlockedFeaturesDescription'.tr(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
@ -292,6 +268,31 @@ class LevelingScreen extends HookConsumerWidget {
) {
final isActive = membership?.isActive ?? false;
Future<void> membershipCancel() async {
if (!isActive || membership == null) return;
final confirm = await showConfirmAlert(
'membershipCancelHint'.tr(),
'membershipCancelConfirm'.tr(),
);
if (!confirm || !context.mounted) return;
try {
showLoadingModal(context);
final client = ref.watch(apiClientProvider);
await client.post('/subscriptions/${membership.identifier}/cancel');
ref.invalidate(accountStellarSubscriptionProvider);
ref.read(userInfoProvider.notifier).fetchUser();
if (context.mounted) {
hideLoadingModal(context);
showSnackBar('membershipCancelSuccess'.tr());
}
} catch (err) {
if (context.mounted) hideLoadingModal(context);
showErrorAlert(err);
}
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
@ -307,7 +308,7 @@ class LevelingScreen extends HookConsumerWidget {
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
@ -327,27 +328,42 @@ class LevelingScreen extends HookConsumerWidget {
if (isActive) ...[
_buildCurrentMembershipCard(context, membership!),
const Gap(16),
const Gap(12),
FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.error,
),
foregroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.onError,
),
),
onPressed: membershipCancel,
icon: const Icon(Symbols.cancel),
label: Text('membershipCancel'.tr()),
),
],
Text(
isActive ? 'upgradeYourPlan'.tr() : 'chooseYourPlan'.tr(),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Gap(12),
_buildMembershipTiers(context, ref, membership),
const Gap(12),
if (!isActive) ...[
Text(
'chooseYourPlan'.tr(),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Gap(12),
_buildMembershipTiers(context, ref, membership),
],
// Restore Purchase Button
OutlinedButton.icon(
onPressed: () => _showRestorePurchaseSheet(context, ref),
icon: const Icon(Icons.restore),
label: Text('restorePurchase'.tr()),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
),
// As you know Apple platform need IAP
if (kIsWeb || !(Platform.isIOS || Platform.isMacOS))
OutlinedButton.icon(
onPressed: () => _showRestorePurchaseSheet(context, ref),
icon: const Icon(Icons.restore),
label: Text('restorePurchase'.tr()),
style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48),
),
).padding(top: 12),
],
),
);
@ -410,33 +426,18 @@ class LevelingScreen extends HookConsumerWidget {
'id': 'solian.stellar.primary',
'name': 'membershipTierStellar'.tr(),
'price': 'membershipPriceStellar'.tr(),
'features': [
'membershipFeatureBasic'.tr(),
'membershipFeaturePrioritySupport'.tr(),
'membershipFeatureAdFree'.tr(),
],
'color': Colors.blue,
},
{
'id': 'solian.stellar.nova',
'name': 'membershipTierNova'.tr(),
'price': 'membershipPriceNova'.tr(),
'features': [
'membershipFeatureAllPrimary'.tr(),
'membershipFeatureAdvancedCustomization'.tr(),
'membershipFeatureEarlyAccess'.tr(),
],
'color': Colors.purple,
'color': Colors.indigo,
},
{
'id': 'solian.stellar.supernova',
'name': 'membershipTierSupernova'.tr(),
'price': 'membershipPriceSupernova'.tr(),
'features': [
'membershipFeatureAllNova'.tr(),
'membershipFeatureExclusiveContent'.tr(),
'membershipFeatureVipSupport'.tr(),
],
'color': Colors.orange,
},
];

View File

@ -72,6 +72,8 @@ Future<Color?> accountAppbarForcegroundColor(Ref ref, String uname) async {
@riverpod
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 apiClient = ref.watch(apiClientProvider);
try {
@ -87,6 +89,8 @@ Future<SnChatRoom?> accountDirectChat(Ref ref, String uname) async {
@riverpod
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 apiClient = ref.watch(apiClientProvider);
try {
@ -219,6 +223,8 @@ class AccountProfileScreen extends HookConsumerWidget {
];
}
final user = ref.watch(userInfoProvider);
return account.when(
data:
(data) => AppScaffold(
@ -379,56 +385,60 @@ class AccountProfileScreen extends HookConsumerWidget {
).padding(horizontal: 24),
),
SliverToBoxAdapter(
child: const Divider(height: 1).padding(top: 24, bottom: 12),
),
SliverToBoxAdapter(
child: Row(
spacing: 8,
children: [
Expanded(
child: FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.secondary,
),
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.onSecondary,
),
),
onPressed: relationshipAction,
label:
Text(
if (user.value != null)
SliverToBoxAdapter(
child: const Divider(
height: 1,
).padding(top: 24, bottom: 12),
),
if (user.value != null)
SliverToBoxAdapter(
child: Row(
spacing: 8,
children: [
Expanded(
child: FilledButton.icon(
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? 'addFriendShort'
: 'added',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.person_add)
: const Icon(Symbols.person_check),
? null
: Theme.of(context).colorScheme.secondary,
),
foregroundColor: WidgetStatePropertyAll(
accountRelationship.value == null
? null
: Theme.of(context).colorScheme.onSecondary,
),
),
onPressed: relationshipAction,
label:
Text(
accountRelationship.value == null
? 'addFriendShort'
: 'added',
).tr(),
icon:
accountRelationship.value == null
? const Icon(Symbols.person_add)
: const Icon(Symbols.person_check),
),
),
),
Expanded(
child: FilledButton.icon(
onPressed: directMessageAction,
icon: const Icon(Symbols.message),
label:
Text(
accountChat.value == null
? 'createDirectMessage'
: 'gotoDirectMessage',
maxLines: 1,
).tr(),
Expanded(
child: FilledButton.icon(
onPressed: directMessageAction,
icon: const Icon(Symbols.message),
label:
Text(
accountChat.value == null
? 'createDirectMessage'
: 'gotoDirectMessage',
maxLines: 1,
).tr(),
),
),
),
],
).padding(horizontal: 16),
),
],
).padding(horizontal: 16),
),
SliverToBoxAdapter(
child: const Divider(height: 1).padding(top: 12),
),

View File

@ -51,54 +51,59 @@ class _ArticleDetailContent extends HookConsumerWidget {
);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (article.preview?.imageUrl != null)
Image.network(
article.preview!.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.title,
style: Theme.of(context).textTheme.headlineSmall,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (article.preview?.imageUrl != null)
Image.network(
article.preview!.imageUrl!,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
),
const SizedBox(height: 8),
if (article.feed?.title != null)
Text(
article.feed!.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
article.title,
style: Theme.of(context).textTheme.headlineSmall,
),
),
const Divider(height: 32),
if (article.content != null)
...MarkdownTextContent.buildGenerator(
isDark: Theme.of(context).brightness == Brightness.dark,
).buildWidgets(markdownContent)
else if (article.preview?.description != null)
Text(article.preview!.description!),
const Gap(24),
FilledButton(
onPressed:
() => launchUrlString(
article.url,
mode: LaunchMode.externalApplication,
const SizedBox(height: 8),
if (article.feed?.title != null)
Text(
article.feed!.title,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
child: const Text('Read Full Article'),
const Divider(height: 32),
if (article.content != null)
...MarkdownTextContent.buildGenerator(
isDark: Theme.of(context).brightness == Brightness.dark,
).buildWidgets(markdownContent)
else if (article.preview?.description != null)
Text(article.preview!.description!),
const Gap(24),
FilledButton(
onPressed:
() => launchUrlString(
article.url,
mode: LaunchMode.externalApplication,
),
child: const Text('Read Full Article'),
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
Gap(MediaQuery.of(context).padding.bottom),
],
),
),
],
),
],
),
),
);
}

View File

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

View File

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

View File

@ -47,15 +47,21 @@ class DeveloperHubShellScreen extends StatelessWidget {
Widget build(BuildContext context) {
final isWide = isWideScreen(context);
if (isWide) {
return Row(
children: [
SizedBox(width: 360, child: const DeveloperHubScreen(isAside: true)),
const VerticalDivider(width: 1),
Expanded(child: child),
],
return AppBackground(
isRoot: true,
child: Row(
children: [
SizedBox(
width: 360,
child: const DeveloperHubScreen(isAside: true),
),
const VerticalDivider(width: 1),
Expanded(child: child),
],
),
);
}
return child;
return AppBackground(isRoot: true, child: child);
}
}
@ -238,8 +244,8 @@ class DeveloperHubScreen extends HookConsumerWidget {
),
onTap: () {
context.push(
'/developers/${currentDeveloper.value!.name}/apps',
);
'/developers/${currentDeveloper.value!.name}/apps',
);
},
),
],

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
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
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@ -29,16 +29,16 @@ $PostComposeInitialStateCopyWith<PostComposeInitialState> get copyWith => _$Post
@override
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)
@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
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;
@useResult
$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
@ -66,17 +66,43 @@ class _$PostComposeInitialStateCopyWithImpl<$Res>
/// Create a copy of PostComposeInitialState
/// 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(
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?,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 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()
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);
@override final String? title;
@ -98,6 +124,8 @@ class _PostComposeInitialState implements PostComposeInitialState {
}
@override final int? visibility;
@override final SnPost? replyingTo;
@override final SnPost? forwardingTo;
/// Create a copy of PostComposeInitialState
/// with the given fields replaced by the non-null parameter values.
@ -112,16 +140,16 @@ Map<String, dynamic> toJson() {
@override
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)
@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
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;
@override @useResult
$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
@ -149,18 +177,44 @@ class __$PostComposeInitialStateCopyWithImpl<$Res>
/// Create a copy of PostComposeInitialState
/// 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(
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?,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 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

View File

@ -18,6 +18,14 @@ _PostComposeInitialState _$PostComposeInitialStateFromJson(
.toList() ??
const [],
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(
@ -28,4 +36,6 @@ Map<String, dynamic> _$PostComposeInitialStateToJson(
'content': instance.content,
'attachments': instance.attachments.map((e) => e.toJson()).toList(),
'visibility': instance.visibility,
'replying_to': instance.replyingTo?.toJson(),
'forwarding_to': instance.forwardingTo?.toJson(),
};

View File

@ -238,7 +238,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
children: [
// Publisher row
Card(
margin: EdgeInsets.only(bottom: 8),
margin: EdgeInsets.only(top: 8),
elevation: 1,
child: Padding(
padding: const EdgeInsets.all(12),
@ -265,12 +265,22 @@ class ArticleComposeScreen extends HookConsumerWidget {
});
},
),
const Gap(12),
Text(
state.currentPublisher.value?.name ??
'postPublisherUnselected'.tr(),
style: theme.textTheme.bodyMedium,
),
const Gap(16),
if (state.currentPublisher.value == null)
Text(
'postPublisherUnselected'.tr(),
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, _) {
if (attachments.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
@ -322,8 +339,8 @@ class ArticleComposeScreen extends HookConsumerWidget {
children: [
for (var idx = 0; idx < attachments.length; idx++)
SizedBox(
width: 120,
height: 120,
width: 280,
height: 280,
child: AttachmentPreview(
item: attachments[idx],
progress: progressMap[idx],
@ -348,6 +365,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
delta,
);
},
onInsert:
() => ComposeLogic.insertAttachment(
ref,
state,
idx,
),
),
),
],

View File

@ -9,7 +9,6 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_quick_reply.dart';
import 'package:island/widgets/post/post_replies.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
@ -22,9 +21,10 @@ Future<SnPost?> post(Ref ref, String id) async {
return SnPost.fromJson(resp.data);
}
final postStateProvider = StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>(
(ref, id) => PostState(ref, id),
);
final postStateProvider =
StateNotifierProvider.family<PostState, AsyncValue<SnPost?>, String>(
(ref, id) => PostState(ref, id),
);
class PostState extends StateNotifier<AsyncValue<SnPost?>> {
final Ref _ref;
@ -75,7 +75,9 @@ class PostDetailScreen extends HookConsumerWidget {
backgroundColor: isWide ? Colors.transparent : null,
onUpdate: (newItem) {
// 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),
@ -93,20 +95,25 @@ class PostDetailScreen extends HookConsumerWidget {
right: 0,
child: Material(
elevation: 2,
child: postState.when(
data: (post) => PostQuickReply(
parent: post!,
onPosted: () {
ref.invalidate(postRepliesNotifierProvider(id));
},
),
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
).padding(
bottom: MediaQuery.of(context).padding.bottom + 16,
top: 16,
horizontal: 16,
),
child: postState
.when(
data:
(post) => PostQuickReply(
parent: post!,
onPosted: () {
ref.invalidate(
postRepliesNotifierProvider(id),
);
},
),
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
)
.padding(
bottom: MediaQuery.of(context).padding.bottom + 16,
top: 16,
horizontal: 16,
),
),
),
],

View File

@ -341,26 +341,6 @@ class SettingsScreen extends HookConsumerWidget {
];
final behaviorSettings = [
ListTile(
minLeadingWidth: 48,
title: Text('creatorHub').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.rocket_launch),
trailing: const Icon(Symbols.chevron_right),
onTap: () => context.push('/creators'),
),
// Developer Hub
ListTile(
minLeadingWidth: 48,
title: Text('developerHub').tr(),
contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.hub),
trailing: const Icon(Symbols.chevron_right),
onTap: () => context.push('/developers'),
),
// Auto translate settings
ListTile(
minLeadingWidth: 48,
title: Text('settingsAutoTranslate').tr(),

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(
provisional: true,
alert: true,
@ -97,6 +100,8 @@ Future<void> subscribePushNotification(Dio apiClient) async {
deviceToken,
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
);
} else if (detailedErrors) {
throw Exception("Failed to get device token for push notifications.");
}
}

View File

@ -21,11 +21,23 @@ class AccountName extends StatelessWidget {
@override
Widget build(BuildContext context) {
var nameStyle = (style ?? TextStyle());
if (account.profile.stellarMembership != null) {
nameStyle = nameStyle.copyWith(
color: (switch (account.profile.stellarMembership!.identifier) {
'solian.stellar.primary' => Colors.blueAccent,
'solian.stellar.nova' => Colors.indigoAccent,
'solian.stellar.supernova' => Colors.amberAccent,
_ => null,
}),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(child: Text(account.nick, style: style)),
Flexible(child: Text(account.nick, style: nameStyle)),
if (account.profile.stellarMembership != null)
StellarMembershipMark(membership: account.profile.stellarMembership!),
if (account.profile.verification != null)
@ -87,36 +99,23 @@ class StellarMembershipMark extends StatelessWidget {
Color _getMembershipTierColor(String identifier) {
switch (identifier) {
case 'solian.stellar.primary':
return Colors.amber;
case 'solian.stellar.nova':
return Colors.blue;
case 'solian.stellar.nova':
return Colors.indigo;
case 'solian.stellar.supernova':
return Colors.purple;
return Colors.amber;
default:
return Colors.grey;
}
}
IconData _getMembershipTierIcon(String identifier) {
switch (identifier) {
case 'solian.stellar.primary':
return Symbols.star;
case 'solian.stellar.nova':
return Symbols.auto_awesome;
case 'solian.stellar.supernova':
return Symbols.diamond;
default:
return Symbols.workspace_premium;
}
}
@override
Widget build(BuildContext context) {
if (!membership.isActive) return const SizedBox.shrink();
final tierName = _getMembershipTierName(membership.identifier);
final tierColor = _getMembershipTierColor(membership.identifier);
final tierIcon = _getMembershipTierIcon(membership.identifier);
final tierIcon = Symbols.award_star;
return Tooltip(
richMessage: TextSpan(
@ -124,7 +123,7 @@ class StellarMembershipMark extends StatelessWidget {
children: [
TextSpan(text: '\n'),
TextSpan(
text: 'currentMembership'.tr(args: [tierName]),
text: 'currentMembershipMember'.tr(args: [tierName]),
style: TextStyle(fontWeight: FontWeight.normal),
),
],

View File

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

View File

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

View File

@ -15,6 +15,7 @@ class AttachmentPreview extends StatelessWidget {
final double? progress;
final Function(int)? onMove;
final Function? onDelete;
final Function? onInsert;
final Function? onRequestUpload;
const AttachmentPreview({
super.key,
@ -23,6 +24,7 @@ class AttachmentPreview extends StatelessWidget {
this.onRequestUpload,
this.onMove,
this.onDelete,
this.onInsert,
});
@override
@ -104,7 +106,11 @@ class AttachmentPreview extends StatelessWidget {
style: TextStyle(color: Colors.white),
),
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);
},
),
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

@ -244,7 +244,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
);
}
String _formatFileSize(int bytes) {
String formatFileSize(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
@ -274,7 +274,7 @@ class CloudFileZoomIn extends HookConsumerWidget {
buildInfoRow(
Icons.storage,
'Size',
_formatFileSize(item.size),
formatFileSize(item.size),
),
const Divider(height: 1),
buildInfoRow(

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/config.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:markdown/markdown.dart' as markdown;
import 'package:markdown_widget/markdown_widget.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'image.dart';
@ -23,6 +27,7 @@ class MarkdownTextContent extends HookConsumerWidget {
final TextStyle? linkStyle;
final EdgeInsets? linesMargin;
final bool isSelectable;
final List<SnCloudFile>? attachments;
const MarkdownTextContent({
super.key,
@ -33,6 +38,7 @@ class MarkdownTextContent extends HookConsumerWidget {
this.linkStyle,
this.isSelectable = false,
this.linesMargin,
this.attachments,
});
@override
@ -109,6 +115,29 @@ class MarkdownTextContent extends HookConsumerWidget {
final uri = Uri.parse(url);
if (uri.scheme == 'solian') {
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':
final size = doesEnlargeSticker ? 96.0 : 24.0;
return ClipRRect(
@ -132,9 +161,9 @@ class MarkdownTextContent extends HookConsumerWidget {
);
}
}
final content = UniversalImage(
uri: uri.toString(),
fit: BoxFit.cover,
final content = ConstrainedBox(
constraints: BoxConstraints(maxHeight: 360),
child: UniversalImage(uri: uri.toString(), fit: BoxFit.contain),
);
return content;
},

View File

@ -286,7 +286,7 @@ class _PaymentContentState extends ConsumerState<_PaymentContent> {
}
String _formatCurrency(int amount, String currency) {
final value = amount / 100.0;
final value = amount;
return '${value.toStringAsFixed(2)} $currency';
}

View File

@ -98,19 +98,11 @@ class ComposeLogic {
descriptionController: TextEditingController(
text: originalPost?.description,
),
contentController: TextEditingController(
text:
originalPost?.content ??
(forwardedPost != null
? '''> ${forwardedPost.content}
'''
: null),
),
contentController: TextEditingController(text: originalPost?.content),
visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null),
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
tagsController: tagsController,
categoriesController: categoriesController,
draftId: id,
@ -482,6 +474,23 @@ class ComposeLogic {
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(
WidgetRef ref,
ComposeState state,

View File

@ -11,6 +11,7 @@ import 'package:island/models/post.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart';
import 'package:island/widgets/account/account_name.dart';
@ -55,13 +56,432 @@ class PostItem extends HookConsumerWidget {
final user = ref.watch(userInfoProvider);
final isAuthor = useMemoized(
() => user.hasValue && user.value?.id == item.publisher.accountId,
() => user.value != null && user.value?.id == item.publisher.accountId,
[user],
);
final hasBackground =
ref.watch(backgroundImageFileProvider).valueOrNull != null;
Widget child;
if (item.type == 1 && isFullPost) {
child = Padding(
padding: renderingPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
context.push('/publishers/${item.publisher.name}');
},
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),
],
),
),
Text(
isFullPost
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(context) ?? '',
).fontSize(11),
],
),
),
if (item.visibility != 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getVisibilityIcon(item.visibility),
size: 14,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
_getVisibilityText(item.visibility).tr(),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.secondary,
),
),
],
).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),
);
},
),
),
],
),
],
),
);
} else {
child = Padding(
padding: renderingPadding,
child: Column(
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
GestureDetector(
child: ProfilePictureWidget(file: item.publisher.picture),
onTap: () {
context.push('/publishers/${item.publisher.name}');
},
),
Expanded(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(
mark: item.publisher.verification!,
).padding(left: 4),
Spacer(),
Text(
isFullPost
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(context) ??
'',
).fontSize(11).alignment(Alignment.bottomRight),
const Gap(4),
],
),
// Add visibility indicator if not public (visibility != 0)
if (item.visibility != 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getVisibilityIcon(item.visibility),
size: 14,
color: Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
_getVisibilityText(item.visibility).tr(),
style: TextStyle(
fontSize: 12,
color:
Theme.of(context).colorScheme.secondary,
),
),
],
).padding(top: 2, bottom: 2),
if (item.type == 1)
_ArticlePostDisplay(
item: item,
isFullPost: isFullPost,
)
else ...[
if (item.title?.isNotEmpty ?? false)
Text(
item.title!,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (item.description?.isNotEmpty ?? false)
Text(
item.description!,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
).padding(bottom: 8),
if (item.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: item.content!,
linesMargin:
item.type == 0
? EdgeInsets.only(bottom: 8)
: null,
attachments: item.attachments,
),
],
// Render tags and categories if they exist
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: () {},
),
],
),
],
),
// Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost && item.type != 1)
_PostTruncateHint().padding(
bottom: item.attachments.isNotEmpty ? 8 : null,
),
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 * 0.85,
kWideScreenWidth - 160,
),
minWidth: math.min(
MediaQuery.of(context).size.width * 0.9,
kWideScreenWidth - 160,
),
),
// Render embed links
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 * 0.85,
kWideScreenWidth - 160,
),
margin: EdgeInsets.only(top: 8),
),
)),
],
),
onTap: () {
if (isOpenable) {
context.push('/posts/${item.id}');
}
},
),
),
],
),
Row(
children: [
// Replies count button
Padding(
padding: const EdgeInsets.only(left: 52, 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),
);
}
},
),
),
// Reactions list
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),
);
},
),
),
],
),
],
),
);
}
return ContextMenuWidget(
menuProvider: (_) {
return Menu(
@ -116,14 +536,20 @@ class PostItem extends HookConsumerWidget {
title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply),
callback: () {
context.push('/posts/compose', extra: {'repliedPost': item});
context.push(
'/posts/compose',
extra: PostComposeInitialState(replyingTo: item),
);
},
),
MenuAction(
title: 'forward'.tr(),
image: MenuImage.icon(Symbols.forward),
callback: () {
context.push('/posts/compose', extra: {'forwardedPost': item});
context.push(
'/posts/compose',
extra: PostComposeInitialState(forwardingTo: item),
);
},
),
MenuSeparator(),
@ -154,244 +580,7 @@ class PostItem extends HookConsumerWidget {
},
child: Material(
color: hasBackground ? Colors.transparent : backgroundColor,
child: Padding(
padding: renderingPadding,
child: Column(
spacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 12,
children: [
GestureDetector(
child: ProfilePictureWidget(file: item.publisher.picture),
onTap: () {
context.push('/publishers/${item.publisher.name}');
},
),
Expanded(
child: GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(item.publisher.nick).bold(),
if (item.publisher.verification != null)
VerificationMark(
mark: item.publisher.verification!,
).padding(left: 4),
Spacer(),
Text(
isFullPost
? item.publishedAt?.formatSystem() ?? ''
: item.publishedAt?.formatRelative(
context,
) ??
'',
).fontSize(11).alignment(Alignment.bottomRight),
const Gap(4),
],
),
// Add visibility indicator if not public (visibility != 0)
if (item.visibility != 0)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getVisibilityIcon(item.visibility),
size: 14,
color:
Theme.of(context).colorScheme.secondary,
),
const SizedBox(width: 4),
Text(
_getVisibilityText(item.visibility).tr(),
style: TextStyle(
fontSize: 12,
color:
Theme.of(context).colorScheme.secondary,
),
),
],
).padding(top: 2, bottom: 2),
if (item.title?.isNotEmpty ?? false)
Text(
item.title!,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (item.description?.isNotEmpty ?? false)
Text(
item.description!,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color:
Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
).padding(bottom: 8),
if (item.content?.isNotEmpty ?? false)
MarkdownTextContent(
content: item.content!,
linesMargin:
item.type == 0
? EdgeInsets.only(bottom: 8)
: null,
),
// Render tags and categories if they exist
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: () {},
),
],
),
],
),
// Show truncation hint if post is truncated
if (item.isTruncated && !isFullPost)
_PostTruncateHint().padding(
bottom: item.attachments.isNotEmpty ? 8 : null,
),
if ((item.repliedPost != null ||
item.forwardedPost != null) &&
showReferencePost)
_buildReferencePost(context, item),
if (item.attachments.isNotEmpty)
CloudFileList(
files: item.attachments,
maxWidth: math.min(
MediaQuery.of(context).size.width * 0.85,
kWideScreenWidth - 160,
),
minWidth: math.min(
MediaQuery.of(context).size.width * 0.9,
kWideScreenWidth - 160,
),
),
// Render embed links
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 * 0.85,
kWideScreenWidth - 160,
),
margin: EdgeInsets.only(top: 8),
),
)),
],
),
onTap: () {
if (isOpenable) {
context.push('/posts/${item.id}');
}
},
),
),
],
),
Row(
children: [
// Replies count button
Padding(
padding: const EdgeInsets.only(left: 52, 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),
);
}
},
),
),
// Reactions list
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: child,
),
);
}
@ -501,6 +690,7 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
referencePost.type == 0
? EdgeInsets.only(bottom: 4)
: null,
attachments: item.attachments,
).padding(bottom: 4),
// Truncation hint for referenced post
if (referencePost.isTruncated)
@ -508,7 +698,8 @@ Widget _buildReferencePost(BuildContext context, SnPost item) {
isCompact: true,
margin: const EdgeInsets.only(top: 4, bottom: 8),
),
if (referencePost.attachments.isNotEmpty)
if (referencePost.attachments.isNotEmpty &&
referencePost.type != 1)
Row(
mainAxisSize: MainAxisSize.min,
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
IconData _getVisibilityIcon(int visibility) {
switch (visibility) {

View File

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

View File

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

View File

@ -1025,7 +1025,7 @@ packages:
source: hosted
version: "4.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
@ -1097,10 +1097,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: ac294be30ba841830cfa146e5a3b22bb09f8dc5a0fdd9ca9332b04b0bde99ebf
sha256: c489908a54ce2131f1d1b7cc631af9c1a06fac5ca7c449e959192089f9489431
url: "https://pub.dev"
source: hosted
version: "15.2.4"
version: "16.0.0"
google_fonts:
dependency: "direct main"
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
# 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.
version: 3.0.0+110
version: 3.0.0+111
environment:
sdk: ^3.7.2
@ -30,6 +30,8 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
@ -37,7 +39,7 @@ dependencies:
flutter_hooks: ^0.21.2
hooks_riverpod: ^2.6.1
bitsdojo_window: ^0.1.6
go_router: ^15.1.3
go_router: ^16.0.0
styled_widget: ^0.4.1
shared_preferences: ^2.5.3
flutter_riverpod: ^2.6.1