Two column explore

This commit is contained in:
LittleSheep 2025-05-24 02:52:31 +08:00
parent 4f9bf960d9
commit 1b544c2c8b
11 changed files with 265 additions and 137 deletions

View File

@ -42,6 +42,12 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="com.superlist.super_native_extensions.DataProvider"
android:authorities="dev.solsynth.solian.SuperClipboardDataProvider"
android:exported="true"
android:grantUriPermissions="true" >
</provider>
<!-- Don't delete the meta-data below. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

View File

@ -9,7 +9,16 @@ class AppRouter extends RootStackRouter {
@override @override
List<AutoRoute> get routes => [ List<AutoRoute> get routes => [
RedirectRoute(path: '/', redirectTo: '/explore'), RedirectRoute(path: '/', redirectTo: '/explore'),
AutoRoute(page: ExploreRoute.page, path: '/explore'), AutoRoute(
page: ExploreShellRoute.page,
path: '/explore',
children: [
AutoRoute(page: ExploreRoute.page, path: ''),
AutoRoute(page: PostComposeRoute.page, path: 'posts/compose'),
AutoRoute(page: PostDetailRoute.page, path: 'posts/:id'),
AutoRoute(page: PostEditRoute.page, path: 'posts/:id/edit'),
],
),
AutoRoute( AutoRoute(
page: AccountShellRoute.page, page: AccountShellRoute.page,
path: '/account', path: '/account',
@ -64,9 +73,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: LoginRoute.page, path: '/auth/login'), AutoRoute(page: LoginRoute.page, path: '/auth/login'),
AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'), AutoRoute(page: CreateAccountRoute.page, path: '/auth/create-account'),
AutoRoute(page: SettingsRoute.page, path: '/settings'), AutoRoute(page: SettingsRoute.page, path: '/settings'),
AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'),
AutoRoute(page: PostDetailRoute.page, path: '/posts/:id'),
AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'),
AutoRoute(page: NewRealmRoute.page, path: '/realms/new'), AutoRoute(page: NewRealmRoute.page, path: '/realms/new'),
AutoRoute(page: RealmDetailRoute.page, path: '/realms/:slug'), AutoRoute(page: RealmDetailRoute.page, path: '/realms/:slug'),
AutoRoute(page: EditRealmRoute.page, path: '/realms/:slug/edit'), AutoRoute(page: EditRealmRoute.page, path: '/realms/:slug/edit'),

View File

@ -598,16 +598,55 @@ class EditStickersRouteArgs {
/// generated route for /// generated route for
/// [_i13.ExploreScreen] /// [_i13.ExploreScreen]
class ExploreRoute extends _i26.PageRouteInfo<void> { class ExploreRoute extends _i26.PageRouteInfo<ExploreRouteArgs> {
const ExploreRoute({List<_i26.PageRouteInfo>? children}) ExploreRoute({
: super(ExploreRoute.name, initialChildren: children); _i27.Key? key,
bool isAside = false,
List<_i26.PageRouteInfo>? children,
}) : super(
ExploreRoute.name,
args: ExploreRouteArgs(key: key, isAside: isAside),
initialChildren: children,
);
static const String name = 'ExploreRoute'; static const String name = 'ExploreRoute';
static _i26.PageInfo page = _i26.PageInfo( static _i26.PageInfo page = _i26.PageInfo(
name, name,
builder: (data) { builder: (data) {
return const _i13.ExploreScreen(); final args = data.argsAs<ExploreRouteArgs>(
orElse: () => const ExploreRouteArgs(),
);
return _i13.ExploreScreen(key: args.key, isAside: args.isAside);
},
);
}
class ExploreRouteArgs {
const ExploreRouteArgs({this.key, this.isAside = false});
final _i27.Key? key;
final bool isAside;
@override
String toString() {
return 'ExploreRouteArgs{key: $key, isAside: $isAside}';
}
}
/// generated route for
/// [_i13.ExploreShellScreen]
class ExploreShellRoute extends _i26.PageRouteInfo<void> {
const ExploreShellRoute({List<_i26.PageRouteInfo>? children})
: super(ExploreShellRoute.name, initialChildren: children);
static const String name = 'ExploreShellRoute';
static _i26.PageInfo page = _i26.PageInfo(
name,
builder: (data) {
return const _i13.ExploreShellScreen();
}, },
); );
} }

View File

@ -31,9 +31,9 @@ class AccountShellScreen extends HookConsumerWidget {
isRoot: true, isRoot: true,
child: Row( child: Row(
children: [ children: [
SizedBox(width: 360, child: AccountScreen(isAside: true)), Flexible(flex: 2, child: AccountScreen(isAside: true)),
VerticalDivider(width: 1), VerticalDivider(width: 1),
Expanded(child: AutoRouter()), Flexible(flex: 3, child: AutoRouter()),
], ],
), ),
); );

View File

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -30,6 +31,7 @@ import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:super_clipboard/super_clipboard.dart';
import 'chat.dart'; import 'chat.dart';
part 'room.g.dart'; part 'room.g.dart';
@ -667,6 +669,9 @@ class ChatRoomScreen extends HookConsumerWidget {
clone.insert(idx + delta, clone.removeAt(idx)); clone.insert(idx + delta, clone.removeAt(idx));
attachments.value = clone; attachments.value = clone;
}, },
onAttachmentsChanged: (newAttachments) {
attachments.value = newAttachments;
},
), ),
error: (_, __) => const SizedBox.shrink(), error: (_, __) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
@ -690,6 +695,7 @@ class _ChatInput extends ConsumerWidget {
final Function(int) onUploadAttachment; final Function(int) onUploadAttachment;
final Function(int) onDeleteAttachment; final Function(int) onDeleteAttachment;
final Function(int, int) onMoveAttachment; final Function(int, int) onMoveAttachment;
final Function(List<UniversalFile>) onAttachmentsChanged;
const _ChatInput({ const _ChatInput({
required this.messageController, required this.messageController,
@ -704,14 +710,22 @@ class _ChatInput extends ConsumerWidget {
required this.onUploadAttachment, required this.onUploadAttachment,
required this.onDeleteAttachment, required this.onDeleteAttachment,
required this.onMoveAttachment, required this.onMoveAttachment,
required this.onAttachmentsChanged,
}); });
void _handleKeyPress(BuildContext context, WidgetRef ref, RawKeyEvent event) { void _handleKeyPress(BuildContext context, WidgetRef ref, RawKeyEvent event) {
if (event is! RawKeyDownEvent) return; if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
if (isPaste && isModifierPressed) {
_handlePaste();
return;
}
final enterToSend = ref.read(appSettingsProvider).enterToSend; final enterToSend = ref.read(appSettingsProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter; final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
if (isEnter) { if (isEnter) {
if (enterToSend && !isModifierPressed) { if (enterToSend && !isModifierPressed) {
@ -722,6 +736,36 @@ class _ChatInput extends ConsumerWidget {
} }
} }
Future<void> _handlePaste() async {
final clipboard = SystemClipboard.instance;
if (clipboard == null) return;
final reader = await clipboard.read();
if (reader.canProvide(Formats.png)) {
reader.getFile(Formats.png, (file) async {
final stream = file.getStream();
final bytes = await stream.toList();
final imageBytes = bytes.expand((e) => e).toList();
// Create a temporary file to store the image
final tempDir = Directory.systemTemp;
final tempFile = File(
'${tempDir.path}/pasted_image_${DateTime.now().millisecondsSinceEpoch}.png',
);
await tempFile.writeAsBytes(imageBytes);
// Add the file to attachments
onAttachmentsChanged([
...attachments,
UniversalFile(
data: XFile(tempFile.path),
type: UniversalFileType.image,
),
]);
});
}
}
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final enterToSend = ref.watch(appSettingsProvider).enterToSend; final enterToSend = ref.watch(appSettingsProvider).enterToSend;
@ -748,7 +792,7 @@ class _ChatInput extends ConsumerWidget {
}, },
separatorBuilder: (_, __) => const Gap(8), separatorBuilder: (_, __) => const Gap(8),
), ),
), ).padding(top: 12),
if (messageReplyingTo != null || if (messageReplyingTo != null ||
messageForwardingTo != null || messageForwardingTo != null ||
messageEditingTo != null) messageEditingTo != null)

View File

@ -21,15 +21,44 @@ import 'package:island/pods/network.dart';
part 'explore.g.dart'; part 'explore.g.dart';
@RoutePage() @RoutePage()
class ExploreScreen extends ConsumerWidget { class ExploreShellScreen extends ConsumerWidget {
const ExploreScreen({super.key}); const ExploreShellScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final activitiesNotifier = ref.watch(activityListNotifierProvider.notifier);
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
if (isWide) {
return AppBackground(
isRoot: true,
child: Row(
children: [
Flexible(flex: 2, child: ExploreScreen(isAside: true)),
VerticalDivider(width: 1),
Flexible(flex: 3, child: AutoRouter()),
],
),
);
}
return AppBackground(isRoot: true, child: AutoRouter());
}
}
@RoutePage()
class ExploreScreen extends ConsumerWidget {
final bool isAside;
const ExploreScreen({super.key, this.isAside = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context);
if (isWide && !isAside) {
return const EmptyPageHolder();
}
final activitiesNotifier = ref.watch(activityListNotifierProvider.notifier);
return TourTriggerWidget( return TourTriggerWidget(
child: AppScaffold( child: AppScaffold(
appBar: AppBar(title: const Text('explore').tr()), appBar: AppBar(title: const Text('explore').tr()),
@ -53,38 +82,11 @@ class ExploreScreen extends ConsumerWidget {
notifierRefreshable: activityListNotifierProvider.notifier, notifierRefreshable: activityListNotifierProvider.notifier,
contentBuilder: contentBuilder:
(data, widgetCount, endItemView) => Center( (data, widgetCount, endItemView) => Center(
child: ConstrainedBox( child: _ActivityListView(
constraints: const BoxConstraints( data: data,
maxWidth: kWideScreenWidth - 160, widgetCount: widgetCount,
), endItemView: endItemView,
child: activitiesNotifier: activitiesNotifier,
isWide
? Card(
elevation: 8,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
color: Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0.8),
child: _ActivityListView(
data: data,
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
),
)
: _ActivityListView(
data: data,
widgetCount: widgetCount,
endItemView: endItemView,
activitiesNotifier: activitiesNotifier,
),
), ),
), ),
), ),

View File

@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -23,6 +24,7 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/post/publishers_modal.dart'; import 'package:island/widgets/post/publishers_modal.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_clipboard/super_clipboard.dart';
@RoutePage() @RoutePage()
class PostEditScreen extends HookConsumerWidget { class PostEditScreen extends HookConsumerWidget {
@ -215,6 +217,47 @@ class PostComposeScreen extends HookConsumerWidget {
} }
} }
Future<void> _handlePaste() async {
final clipboard = SystemClipboard.instance;
if (clipboard == null) return;
final reader = await clipboard.read();
if (reader.canProvide(Formats.png)) {
reader.getFile(Formats.png, (file) async {
final stream = file.getStream();
final bytes = await stream.toList();
final imageBytes = bytes.expand((e) => e).toList();
// Create a temporary file to store the image
final tempDir = Directory.systemTemp;
final tempFile = File(
'${tempDir.path}/pasted_image_${DateTime.now().millisecondsSinceEpoch}.png',
);
await tempFile.writeAsBytes(imageBytes);
// Add the file to attachments
attachments.value = [
...attachments.value,
UniversalFile(
data: XFile(tempFile.path),
type: UniversalFileType.image,
),
];
});
}
}
void _handleKeyPress(RawKeyEvent event) {
if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
if (isPaste && isModifierPressed) {
_handlePaste();
}
}
return AppScaffold( return AppScaffold(
appBar: AppBar( appBar: AppBar(
leading: const PageBackButton(), leading: const PageBackButton(),
@ -291,17 +334,22 @@ class PostComposeScreen extends HookConsumerWidget {
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(8), const Gap(8),
TextField( RawKeyboardListener(
controller: contentController, focusNode: FocusNode(),
style: TextStyle(fontSize: 14), onKey: _handleKeyPress,
decoration: InputDecoration( child: TextField(
border: InputBorder.none, controller: contentController,
hintText: 'postPlaceholder'.tr(), style: TextStyle(fontSize: 14),
isDense: true, decoration: InputDecoration(
border: InputBorder.none,
hintText: 'postPlaceholder'.tr(),
isDense: true,
),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus
?.unfocus(),
), ),
onTapOutside:
(_) =>
FocusManager.instance.primaryFocus?.unfocus(),
), ),
const Gap(8), const Gap(8),
Column( Column(

View File

@ -36,19 +36,25 @@ class PostDetailScreen extends HookConsumerWidget {
appBar: AppBar(title: const Text('Post')), appBar: AppBar(title: const Text('Post')),
body: post.when( body: post.when(
data: (post) { data: (post) {
final content = Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Column( CustomScrollView(
children: [ slivers: [
PostItem( SliverToBoxAdapter(
item: post!, child: Column(
isOpenable: false, children: [
backgroundColor: isWide ? Colors.transparent : null, PostItem(
item: post!,
isOpenable: false,
backgroundColor: isWide ? Colors.transparent : null,
),
const Divider(height: 1),
],
),
), ),
const Divider(height: 1), PostRepliesList(postId: id),
Expanded(child: PostRepliesList(postId: id)), SliverGap(MediaQuery.of(context).padding.bottom + 80),
Gap(MediaQuery.of(context).padding.bottom),
], ],
), ),
Positioned( Positioned(
@ -67,30 +73,6 @@ class PostDetailScreen extends HookConsumerWidget {
), ),
], ],
); );
return isWide
? Center(
child: Card(
elevation: 8,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
color: Theme.of(
context,
).colorScheme.surfaceContainerLow.withOpacity(0.8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: kWideScreenWidth - 160,
),
child: content,
),
),
)
: content;
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('Error: $e'), error: (e, _) => Text('Error: $e'),

View File

@ -18,56 +18,48 @@ class PostRepliesList extends HookConsumerWidget {
final postAsync = ref.watch(postRepliesProvider(postId)); final postAsync = ref.watch(postRepliesProvider(postId));
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
return RefreshIndicator( return postAsync.when(
onRefresh: data:
() => Future.sync((() { (controller) => SliverInfiniteList(
ref.invalidate(postRepliesProvider(postId)); itemCount: controller.posts.length,
})), isLoading: controller.isLoading,
child: postAsync.when( hasReachedMax: controller.hasReachedMax,
data: onFetchData: controller.fetchMore,
(controller) => RefreshIndicator( itemBuilder: (context, index) {
onRefresh: final post = controller.posts[index];
() => Future.sync((() { return PostItem(
ref.invalidate(postRepliesProvider(postId)); item: post,
})), backgroundColor: isWide ? Colors.transparent : null,
child: InfiniteList( );
padding: EdgeInsets.only( },
bottom: MediaQuery.of(context).padding.bottom, separatorBuilder: (_, __) => const Divider(height: 1),
), emptyBuilder: (context) {
itemCount: controller.posts.length, return SliverToBoxAdapter(
isLoading: controller.isLoading, child: Column(
hasReachedMax: controller.hasReachedMax, children: [
onFetchData: controller.fetchMore, Text(
itemBuilder: (context, index) { 'No replies',
final post = controller.posts[index]; textAlign: TextAlign.center,
return PostItem( ).fontSize(18).bold(),
item: post, Text('Why not start a discussion?'),
backgroundColor: isWide ? Colors.transparent : null, ],
); ).padding(vertical: 16),
}, );
separatorBuilder: (_, __) => const Divider(height: 1), },
emptyBuilder: (context) { ),
return Column( loading:
children: [ () => SliverFillRemaining(
Text( child: const Center(child: CircularProgressIndicator()),
'No replies', ),
textAlign: TextAlign.center, error:
).fontSize(18).bold(), (e, _) => SliverFillRemaining(
Text('Why not start a discussion?'), child: ResponseErrorWidget(
],
).padding(vertical: 16);
},
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(e, _) => ResponseErrorWidget(
error: e, error: e,
onRetry: () { onRetry: () {
ref.invalidate(postRepliesProvider(postId)); ref.invalidate(postRepliesProvider(postId));
}, },
), ),
), ),
); );
} }
} }

View File

@ -1802,6 +1802,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.4.1" version: "0.4.1"
super_clipboard:
dependency: "direct main"
description:
name: super_clipboard
sha256: "5203c881d24033c3e6154c2ae01afd94e7f0a3201280373f28e540f1defa3f40"
url: "https://pub.dev"
source: hosted
version: "0.9.0-dev.6"
super_context_menu: super_context_menu:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -100,6 +100,7 @@ dependencies:
photo_view: ^0.15.0 photo_view: ^0.15.0
dismissible_page: ^1.0.2 dismissible_page: ^1.0.2
super_sliver_list: ^0.4.1 super_sliver_list: ^0.4.1
super_clipboard: ^0.9.0-dev.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: