✨ Command pattle basis
This commit is contained in:
@@ -12,8 +12,10 @@ import 'package:island/pods/config.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/services/event_bus.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/cmp/pattle.dart';
|
||||
import 'package:island/widgets/upload_overlay.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -36,6 +38,14 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isMaximized = useState(false);
|
||||
final showPalette = useState(false);
|
||||
final lastShiftTime = useState<DateTime?>(null);
|
||||
final keyboardFocusNode = useFocusNode();
|
||||
|
||||
useEffect(() {
|
||||
keyboardFocusNode.requestFocus();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Add window resize listener for desktop platforms
|
||||
useEffect(() {
|
||||
@@ -68,6 +78,15 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Event bus listener for command palette
|
||||
final subscription = useMemoized(
|
||||
() => eventBus.on<CommandPaletteTriggerEvent>().listen(
|
||||
(_) => showPalette.value = true,
|
||||
),
|
||||
[],
|
||||
);
|
||||
useEffect(() => subscription.cancel, [subscription]);
|
||||
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
final pageActionsButton = [
|
||||
@@ -98,19 +117,32 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
DragToMoveArea(
|
||||
child:
|
||||
Platform.isMacOS
|
||||
? Stack(
|
||||
child: KeyboardListener(
|
||||
focusNode: keyboardFocusNode,
|
||||
onKeyEvent: (event) {
|
||||
if (event is KeyDownEvent &&
|
||||
(event.logicalKey == LogicalKeyboardKey.shiftLeft ||
|
||||
event.logicalKey == LogicalKeyboardKey.shiftRight)) {
|
||||
final now = DateTime.now();
|
||||
if (lastShiftTime.value != null &&
|
||||
now.difference(lastShiftTime.value!).inMilliseconds < 300) {
|
||||
showPalette.value = true;
|
||||
}
|
||||
lastShiftTime.value = now;
|
||||
}
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||
child: Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
DragToMoveArea(
|
||||
child: Platform.isMacOS
|
||||
? Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isWideScreen(context))
|
||||
@@ -126,15 +158,14 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
@@ -193,13 +224,18 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(
|
||||
onDismiss: () => showPalette.value = false,
|
||||
),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -210,15 +246,32 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
shortcuts: <LogicalKeySet, Intent>{
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): const PopIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(child: child),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
],
|
||||
child: KeyboardListener(
|
||||
focusNode: keyboardFocusNode,
|
||||
onKeyEvent: (event) {
|
||||
if (event is KeyDownEvent &&
|
||||
(event.logicalKey == LogicalKeyboardKey.shiftLeft ||
|
||||
event.logicalKey == LogicalKeyboardKey.shiftRight)) {
|
||||
final now = DateTime.now();
|
||||
if (lastShiftTime.value != null &&
|
||||
now.difference(lastShiftTime.value!).inMilliseconds < 300) {
|
||||
showPalette.value = true;
|
||||
}
|
||||
lastShiftTime.value = now;
|
||||
}
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(child: child),
|
||||
_WebSocketIndicator(),
|
||||
const UploadOverlay(),
|
||||
if (showPalette.value)
|
||||
CommandPattleWidget(onDismiss: () => showPalette.value = false),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -407,8 +460,8 @@ class PageBackButton extends StatelessWidget {
|
||||
color: color,
|
||||
context.canPop()
|
||||
? (!kIsWeb && (Platform.isMacOS || Platform.isIOS))
|
||||
? Symbols.arrow_back_ios_new
|
||||
: Symbols.arrow_back
|
||||
? Symbols.arrow_back_ios_new
|
||||
: Symbols.arrow_back
|
||||
: Symbols.home,
|
||||
shadows: shadows,
|
||||
),
|
||||
@@ -463,11 +516,10 @@ class AppBackground extends ConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(),
|
||||
error:
|
||||
(_, _) => Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
),
|
||||
error: (_, _) => Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -519,10 +571,10 @@ class _WebSocketIndicator extends HookConsumerWidget {
|
||||
duration: Duration(milliseconds: 1850),
|
||||
top:
|
||||
user.value == null ||
|
||||
user.value == null ||
|
||||
websocketState == WebSocketState.connected()
|
||||
? -indicatorHeight
|
||||
: 0,
|
||||
user.value == null ||
|
||||
websocketState == WebSocketState.connected()
|
||||
? -indicatorHeight
|
||||
: 0,
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -531,17 +583,16 @@ class _WebSocketIndicator extends HookConsumerWidget {
|
||||
child: Material(
|
||||
elevation:
|
||||
user.value == null || websocketState == WebSocketState.connected()
|
||||
? 0
|
||||
: 4,
|
||||
? 0
|
||||
: 4,
|
||||
child: AnimatedContainer(
|
||||
duration: Duration(milliseconds: 300),
|
||||
color: indicatorColor,
|
||||
child: Center(
|
||||
child:
|
||||
Text(
|
||||
indicatorText,
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
).tr(),
|
||||
child: Text(
|
||||
indicatorText,
|
||||
style: TextStyle(color: Colors.white, fontSize: 16),
|
||||
).tr(),
|
||||
).padding(top: MediaQuery.of(context).padding.top),
|
||||
),
|
||||
),
|
||||
|
||||
336
lib/widgets/cmp/pattle.dart
Normal file
336
lib/widgets/cmp/pattle.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/chat/chat_room.dart';
|
||||
import 'package:island/pods/chat/chat_summary.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
class CommandPattleWidget extends HookConsumerWidget {
|
||||
final VoidCallback onDismiss;
|
||||
|
||||
const CommandPattleWidget({super.key, required this.onDismiss});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final textController = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final searchQuery = useState('');
|
||||
final focusedIndex = useState<int?>(null);
|
||||
|
||||
useEffect(() {
|
||||
focusNode.requestFocus();
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
useEffect(() {
|
||||
void listener() {
|
||||
searchQuery.value = textController.text;
|
||||
// Reset focused index when search changes
|
||||
focusedIndex.value = null;
|
||||
}
|
||||
|
||||
textController.addListener(listener);
|
||||
return () => textController.removeListener(listener);
|
||||
}, [textController]);
|
||||
|
||||
final chatRooms = ref.watch(chatRoomJoinedProvider);
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
|
||||
bool isDesktop() =>
|
||||
kIsWeb ||
|
||||
(!kIsWeb &&
|
||||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS));
|
||||
|
||||
final filteredRooms = chatRooms.maybeWhen(
|
||||
data: (rooms) {
|
||||
if (searchQuery.value.isEmpty) return <SnChatRoom>[];
|
||||
return rooms
|
||||
.where((room) {
|
||||
final title = room.name ?? '';
|
||||
final desc = room.description ?? '';
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
return title.toLowerCase().contains(query) ||
|
||||
desc.toLowerCase().contains(query) ||
|
||||
(room.members?.any(
|
||||
(member) =>
|
||||
member.account.name.contains(query) ||
|
||||
member.account.nick.contains(query),
|
||||
) ??
|
||||
false);
|
||||
})
|
||||
.take(5) // Limit to 5 results
|
||||
.toList();
|
||||
},
|
||||
orElse: () => <SnChatRoom>[],
|
||||
);
|
||||
|
||||
return KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent: (event) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onDismiss();
|
||||
} else if (isDesktop()) {
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter ||
|
||||
event.logicalKey == LogicalKeyboardKey.numpadEnter) {
|
||||
if (focusedIndex.value != null &&
|
||||
focusedIndex.value! < filteredRooms.length) {
|
||||
_navigateToRoom(
|
||||
context,
|
||||
ref,
|
||||
filteredRooms[focusedIndex.value!],
|
||||
);
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
if (filteredRooms.isNotEmpty) {
|
||||
if (focusedIndex.value == null) {
|
||||
focusedIndex.value = 0;
|
||||
} else {
|
||||
focusedIndex.value = math.max(0, focusedIndex.value! - 1);
|
||||
}
|
||||
}
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
if (filteredRooms.isNotEmpty) {
|
||||
if (focusedIndex.value == null) {
|
||||
focusedIndex.value = 0;
|
||||
} else {
|
||||
focusedIndex.value = math.min(
|
||||
filteredRooms.length - 1,
|
||||
focusedIndex.value! + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: onDismiss,
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {}, // Prevent tap from dismissing when tapping inside
|
||||
child: Container(
|
||||
width: math.max(MediaQuery.of(context).size.width * 0.6, 320),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 600,
|
||||
maxHeight: 500,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SearchBar(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
hintText: 'Search chats...',
|
||||
leading: const Icon(
|
||||
Symbols.keyboard_command_key,
|
||||
).padding(horizontal: 8),
|
||||
onSubmitted: (_) {
|
||||
if (filteredRooms.isNotEmpty) {
|
||||
_navigateToRoom(context, ref, filteredRooms.first);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (filteredRooms.isNotEmpty)
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: filteredRooms.length,
|
||||
itemBuilder: (context, index) {
|
||||
final room = filteredRooms[index];
|
||||
return _ChatRoomSearchResult(
|
||||
room: room,
|
||||
isFocused: index == focusedIndex.value,
|
||||
onTap: () =>
|
||||
_navigateToRoom(context, ref, room),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToRoom(BuildContext context, WidgetRef ref, SnChatRoom room) {
|
||||
onDismiss();
|
||||
if (isWideScreen(context)) {
|
||||
ref
|
||||
.read(routerProvider)
|
||||
.replaceNamed('chatRoom', pathParameters: {'id': room.id});
|
||||
} else {
|
||||
ref
|
||||
.read(routerProvider)
|
||||
.pushNamed('chatRoom', pathParameters: {'id': room.id});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatRoomSearchResult extends HookConsumerWidget {
|
||||
final SnChatRoom room;
|
||||
final bool isFocused;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ChatRoomSearchResult({
|
||||
required this.room,
|
||||
required this.isFocused,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final userInfo = ref.watch(userInfoProvider);
|
||||
final summary = ref
|
||||
.watch(chatSummaryProvider)
|
||||
.whenData((summaries) => summaries[room.id]);
|
||||
|
||||
var validMembers = room.members ?? [];
|
||||
if (validMembers.isNotEmpty && userInfo.value != null) {
|
||||
validMembers = validMembers
|
||||
.where((e) => e.accountId != userInfo.value!.id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
String titleText;
|
||||
if (room.type == 1 && room.name == null) {
|
||||
if (room.members?.isNotEmpty ?? false) {
|
||||
titleText = validMembers.map((e) => e.account.nick).join(', ');
|
||||
} else {
|
||||
titleText = 'Direct Message';
|
||||
}
|
||||
} else {
|
||||
titleText = room.name ?? '';
|
||||
}
|
||||
|
||||
Widget buildSubtitle() {
|
||||
return summary.when(
|
||||
data: (data) => data == null
|
||||
? (room.type == 1 && room.description == null
|
||||
? Text(
|
||||
validMembers.map((e) => '@${e.account.name}').join(', '),
|
||||
)
|
||||
: Text(room.description ?? ''))
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (data.unreadCount > 0)
|
||||
Text(
|
||||
'unreadMessages'.plural(data.unreadCount),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (data.lastMessage == null)
|
||||
room.type == 1 && room.description == null
|
||||
? Text(
|
||||
validMembers
|
||||
.map((e) => '@${e.account.name}')
|
||||
.join(', '),
|
||||
)
|
||||
: Text(room.description ?? '')
|
||||
else
|
||||
Row(
|
||||
spacing: 4,
|
||||
children: [
|
||||
Badge(
|
||||
label: Text(data.lastMessage!.sender.account.nick),
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
(data.lastMessage!.content?.isNotEmpty ?? false)
|
||||
? data.lastMessage!.content!
|
||||
: 'messageNone'.tr(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
RelativeTime(
|
||||
context,
|
||||
).format(data.lastMessage!.createdAt),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
loading: () => room.type == 1 && room.description == null
|
||||
? Text(validMembers.map((e) => '@${e.account.name}').join(', '))
|
||||
: Text(room.description ?? ''),
|
||||
error: (_, _) => room.type == 1 && room.description == null
|
||||
? Text(validMembers.map((e) => '@${e.account.name}').join(', '))
|
||||
: Text(room.description ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
final isDirect = room.type == 1;
|
||||
|
||||
return ListTile(
|
||||
tileColor: isFocused
|
||||
? Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
: null,
|
||||
leading: Badge(
|
||||
isLabelVisible: summary.maybeWhen(
|
||||
data: (data) => (data?.unreadCount ?? 0) > 0,
|
||||
orElse: () => false,
|
||||
),
|
||||
child: (isDirect && room.picture?.id == null)
|
||||
? SplitAvatarWidget(
|
||||
filesId: validMembers
|
||||
.map((e) => e.account.profile.picture?.id)
|
||||
.toList(),
|
||||
)
|
||||
: room.picture?.id == null
|
||||
? CircleAvatar(child: Text((room.name ?? 'DM')[0].toUpperCase()))
|
||||
: ProfilePictureWidget(
|
||||
fileId: room.picture?.id,
|
||||
), // Placeholder for now
|
||||
),
|
||||
title: Text(titleText),
|
||||
subtitle: buildSubtitle(),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user