🐛 Fix bugs

This commit is contained in:
2025-11-02 02:21:15 +08:00
parent 88f149584e
commit 12b79af3a2
10 changed files with 142 additions and 417 deletions

View File

@@ -1306,5 +1306,6 @@
"activities": "Activities",
"presenceTypeGaming": "Playing",
"presenceTypeMusic": "Listening to Music",
"presenceTypeWorkout": "Working out"
"presenceTypeWorkout": "Working out",
"articleCompose": "Compose Article"
}

View File

@@ -50,6 +50,7 @@ import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
import 'package:island/screens/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/screens/posts/publisher_profile.dart';
import 'package:island/screens/auth/login.dart';
@@ -106,11 +107,19 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [
// Standalone routes without bottom navigation
GoRoute(
name: 'postEdit',
path: '/posts/:id/edit',
name: 'articleCompose',
path: '/articles/compose',
builder:
(context, state) => ArticleComposeScreen(
initialState: state.extra as PostComposeInitialState?,
),
),
GoRoute(
name: 'articleEdit',
path: '/articles/:id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return PostEditScreen(id: id);
return ArticleEditScreen(id: id);
},
),
GoRoute(

View File

@@ -177,8 +177,14 @@ class PublisherSelector extends StatelessWidget {
iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down),
iconSize: 19,
iconEnabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor: Theme.of(context).appBarTheme.foregroundColor!,
iconEnabledColor:
isWideScreen(context)
? null
: Theme.of(context).appBarTheme.foregroundColor!,
iconDisabledColor:
isWideScreen(context)
? null
: Theme.of(context).appBarTheme.foregroundColor!,
),
),
);
@@ -561,6 +567,7 @@ class CreatorHubScreen extends HookConsumerWidget {
? Column(
spacing: 8,
children: [
const SizedBox.shrink(),
PublisherSelector(
currentPublisher: currentPublisher.value,
publishersMenu: publishersMenu,

View File

@@ -16,6 +16,7 @@ import 'package:island/services/responsive.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/post/post_featured.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/compose_card.dart';
@@ -72,6 +73,22 @@ class ExploreScreen extends HookConsumerWidget {
final tabController = useTabController(initialLength: 3);
final currentFilter = useState<String?>(null);
useEffect(() {
// Set FAB type to chat
final fabMenuNotifier = ref.read(fabMenuTypeProvider.notifier);
Future(() {
fabMenuNotifier.state = FabMenuType.compose;
});
return () {
// Clean up: reset FAB type to main
WidgetsBinding.instance.addPostFrameCallback((_) {
if (fabMenuNotifier.state == FabMenuType.compose) {
fabMenuNotifier.state = FabMenuType.main;
}
});
};
}, []);
useEffect(() {
void listener() {
switch (tabController.index) {
@@ -703,7 +720,9 @@ class ActivityListNotifier extends _$ActivityListNotifier
fetch(cursor: null);
@override
Future<CursorPagingData<SnTimelineEvent>> fetch({required String? cursor}) async {
Future<CursorPagingData<SnTimelineEvent>> fetch({
required String? cursor,
}) async {
final client = ref.read(apiClientProvider);
final take = 20;

View File

@@ -1,27 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/compose_attachments.dart';
import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_info_banner.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
part 'compose.freezed.dart';
part 'compose.g.dart';
@@ -41,358 +20,3 @@ sealed class PostComposeInitialState with _$PostComposeInitialState {
factory PostComposeInitialState.fromJson(Map<String, dynamic> json) =>
_$PostComposeInitialStateFromJson(json);
}
class PostEditScreen extends HookConsumerWidget {
final String id;
const PostEditScreen({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final post = ref.watch(postProvider(id));
return post.when(
data: (post) => PostComposeScreen(originalPost: post),
loading:
() => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: const Center(child: CircularProgressIndicator()),
),
error:
(e, _) => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Text('Error: $e', textAlign: TextAlign.center),
),
);
}
}
class PostComposeScreen extends HookConsumerWidget {
final SnPost? originalPost;
final int? type;
final PostComposeInitialState? initialState;
const PostComposeScreen({
super.key,
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) {
return ArticleComposeScreen(originalPost: originalPost);
}
// When editing, preserve the original replied/forwarded post references
final effectiveRepliedPost = repliedPost ?? originalPost?.repliedPost;
final effectiveForwardedPost = forwardedPost ?? originalPost?.forwardedPost;
final publishers = ref.watch(publishersManagedProvider);
final state = useMemoized(
() => ComposeLogic.createState(
originalPost: originalPost,
forwardedPost: effectiveForwardedPost,
repliedPost: effectiveRepliedPost,
postType: 0, // Regular post type
),
[originalPost, effectiveForwardedPost, effectiveRepliedPost],
);
// Add a listener to the entire state to trigger rebuilds
final stateNotifier = useMemoized(
() => Listenable.merge([
state.titleController,
state.descriptionController,
state.contentController,
state.visibility,
state.attachments,
state.attachmentProgress,
state.currentPublisher,
state.submitting,
]),
[state],
);
useListenable(stateNotifier);
// Start auto-save when component mounts
useEffect(() {
if (originalPost == null) {
// Only auto-save for new posts, not edits
state.startAutoSave(ref);
}
return () => state.stopAutoSave();
}, [state]);
// Initialize publisher once when data is available
useEffect(() {
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;
}
}
return null;
}, [publishers]);
// Load initial state if provided (for sharing functionality)
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState!.title ?? '';
state.descriptionController.text = initialState!.description ?? '';
state.contentController.text = initialState!.content ?? '';
if (initialState!.visibility != null) {
state.visibility.value = initialState!.visibility!;
}
if (initialState!.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState!.attachments);
}
}
return null;
}, [initialState]);
// Load draft if available (only for new posts without initial state)
useEffect(() {
if (originalPost == null &&
effectiveForwardedPost == null &&
effectiveRepliedPost == null &&
initialState == null) {
// Try to load the most recent draft
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
final mostRecentDraft = drafts.values.reduce(
(a, b) =>
(a.updatedAt ?? DateTime(0)).isAfter(b.updatedAt ?? DateTime(0))
? a
: b,
);
// Only load if the draft has meaningful content
if (mostRecentDraft.content?.isNotEmpty == true ||
mostRecentDraft.title?.isNotEmpty == true) {
state.titleController.text = mostRecentDraft.title ?? '';
state.descriptionController.text =
mostRecentDraft.description ?? '';
state.contentController.text = mostRecentDraft.content ?? '';
state.visibility.value = mostRecentDraft.visibility;
}
}
}
return null;
}, []);
// Dispose state when widget is disposed
useEffect(() {
return () {
state.stopAutoSave();
ComposeLogic.dispose(state);
};
}, []);
// Helper methods
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
return PopScope(
onPopInvoked: (_) {
if (originalPost == null) {
ComposeLogic.saveDraft(ref, state);
}
},
child: AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
actions: [
IconButton(
icon: const Icon(Symbols.settings),
onPressed: showSettingsSheet,
tooltip: 'postSettings'.tr(),
),
IconButton(
onPressed:
state.submitting.value
? null
: () => ComposeLogic.performAction(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
),
icon:
state.submitting.value
? SizedBox(
width: 28,
height: 28,
child: const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
).center()
: Icon(
originalPost != null ? Symbols.edit : Symbols.upload,
),
),
const Gap(8),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Reply/Forward info section
ComposeInfoBanner(
originalPost: originalPost,
replyingTo: repliedPost,
forwardingTo: forwardedPost,
onReferencePostTap: (context, post) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder:
(context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.9,
minChildSize: 0.5,
builder:
(context, scrollController) => Container(
decoration: BoxDecoration(
color:
Theme.of(context).scaffoldBackgroundColor,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(
vertical: 8,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.outline,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.all(16),
child: PostItem(item: post),
),
),
],
),
),
),
);
},
),
// Main content area
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Row(
spacing: 12,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Publisher profile picture
GestureDetector(
child: ProfilePictureWidget(
fileId: state.currentPublisher.value?.picture?.id,
radius: 20,
fallbackIcon:
state.currentPublisher.value == null
? Symbols.question_mark
: null,
),
onTap: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => const PublisherModal(),
).then((value) {
if (value != null) {
state.currentPublisher.value = value;
}
});
},
).padding(top: 16),
// Post content form
Expanded(
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent:
(event) => ComposeLogic.handleKeyPress(
event,
state,
ref,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ComposeFormFields(
state: state,
showPublisherAvatar: false,
onPublisherTap: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => const PublisherModal(),
).then((value) {
if (value != null) {
state.currentPublisher.value = value;
}
});
},
),
const Gap(8),
ComposeAttachments(
state: state,
isCompact: false,
),
],
),
),
),
),
],
).padding(horizontal: 16),
).alignment(Alignment.topCenter),
),
// Bottom toolbar
ComposeToolbar(state: state, originalPost: originalPost),
],
),
),
);
}
}

View File

@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
import 'package:island/screens/creators/publishers_form.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/responsive.dart';
@@ -49,8 +50,9 @@ class ArticleEditScreen extends HookConsumerWidget {
class ArticleComposeScreen extends HookConsumerWidget {
final SnPost? originalPost;
final PostComposeInitialState? initialState;
const ArticleComposeScreen({super.key, this.originalPost});
const ArticleComposeScreen({super.key, this.originalPost, this.initialState});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -100,9 +102,25 @@ class ArticleComposeScreen extends HookConsumerWidget {
return null;
}, [publishers]);
// Load initial state if provided (for sharing functionality)
useEffect(() {
if (initialState != null) {
state.titleController.text = initialState!.title ?? '';
state.descriptionController.text = initialState!.description ?? '';
state.contentController.text = initialState!.content ?? '';
if (initialState!.visibility != null) {
state.visibility.value = initialState!.visibility!;
}
if (initialState!.attachments.isNotEmpty) {
state.attachments.value = List.from(initialState!.attachments);
}
}
return null;
}, [initialState]);
// Load draft if available (only for new articles)
useEffect(() {
if (originalPost == null) {
if (originalPost == null && initialState == null) {
// Try to load the most recent article draft
final drafts = ref.read(composeStorageNotifierProvider);
if (drafts.isNotEmpty) {
@@ -199,6 +217,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
border: Border.all(color: colorScheme.outline.withOpacity(0.3)),
borderRadius: BorderRadius.circular(8),
),
margin: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -219,7 +238,12 @@ class ArticleComposeScreen extends HookConsumerWidget {
],
),
),
Expanded(child: widgetItem),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: widgetItem,
),
),
],
),
);
@@ -246,7 +270,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
}
});
},
),
).padding(top: 16),
// Attachments preview
ValueListenableBuilder<List<UniversalFile>>(

View File

@@ -108,13 +108,21 @@ class PostActionButtons extends HookConsumerWidget {
final editButtons = <Widget>[
FilledButton.tonal(
onPressed: () {
context.pushNamed('postEdit', pathParameters: {'id': post.id}).then(
(value) {
if (value != null) {
if (post.type == 1) {
context
.pushNamed('articleEdit', pathParameters: {'id': post.id})
.then((value) {
if (value != null) {
onRefresh?.call();
}
});
} else {
PostComposeSheet.show(context, originalPost: post).then((value) {
if (value == true) {
onRefresh?.call();
}
},
);
});
}
},
style: FilledButton.styleFrom(
shape: const RoundedRectangleBorder(

View File

@@ -51,33 +51,33 @@ class TabsScreen extends HookConsumerWidget {
final destinations = [
NavigationDestination(
label: 'explore'.tr(),
icon: const Icon(Symbols.explore),
icon: const Icon(Symbols.explore_rounded),
),
NavigationDestination(
label: 'chat'.tr(),
icon: const Icon(Symbols.chat_rounded),
icon: const Icon(Symbols.forum_rounded),
),
NavigationDestination(
label: 'realms'.tr(),
icon: const Icon(Symbols.group),
icon: const Icon(Symbols.group_rounded),
),
NavigationDestination(
label: 'account'.tr(),
icon: Badge.count(
count: notificationUnreadCount.value ?? 0,
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
child: const Icon(Symbols.account_circle),
child: const Icon(Symbols.person_rounded),
),
),
if (wideScreen)
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.ink_pen),
icon: const Icon(Symbols.design_services_rounded),
),
if (wideScreen)
NavigationDestination(
label: 'developerHub'.tr(),
icon: const Icon(Symbols.data_object),
icon: const Icon(Symbols.data_object_rounded),
),
];

View File

@@ -11,7 +11,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
enum FabMenuType { main, chat, realm }
enum FabMenuType { main, compose, chat, realm }
/// Global state provider for FAB menu type
final fabMenuTypeProvider = StateProvider<FabMenuType>(
@@ -69,7 +69,7 @@ class FabMenu extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.notifications),
trailing: Badge(
label: Text(notificationCount.toString()),
label: Text(notificationCount.value.toString()),
isLabelVisible: notificationCount.value! > 0,
),
title: Text('notifications').tr(),
@@ -88,6 +88,38 @@ class FabMenu extends HookConsumerWidget {
];
switch (fabType) {
case FabMenuType.compose:
icon = Symbols.create;
useRootNavigator = false;
menuContent = Column(
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.post_add_rounded),
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeSheet.show(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.article),
title: Text('articleCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
GoRouter.of(context).pushNamed('articleCompose');
},
),
const Divider(),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],
);
break;
case FabMenuType.chat:
icon = Symbols.chat_add_on;
useRootNavigator = true;
@@ -160,16 +192,6 @@ class FabMenu extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.post_add_rounded),
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeSheet.show(context);
},
),
const Divider(),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -10,6 +10,7 @@ import 'package:island/services/time.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_shared.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
@@ -45,13 +46,23 @@ class PostItemCreator extends HookConsumerWidget {
title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit),
callback: () {
context
.pushNamed('postEdit', pathParameters: {'id': item.id})
.then((value) {
if (value != null) {
onRefresh?.call();
}
});
if (item.type == 1) {
context
.pushNamed('articleEdit', pathParameters: {'id': item.id})
.then((value) {
if (value != null) {
onRefresh?.call();
}
});
} else {
PostComposeSheet.show(context, originalPost: item).then((
value,
) {
if (value == true) {
onRefresh?.call();
}
});
}
},
),
MenuAction(