fluffychat/lib/pages/chat_list/chat_list_body.dart

470 lines
17 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:animations/animations.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:fluffychat/pages/chat_list/chat_list.dart';
import 'package:fluffychat/pages/chat_list/chat_list_item.dart';
import 'package:fluffychat/pages/chat_list/search_title.dart';
import 'package:fluffychat/pages/chat_list/spaces_entry.dart';
import 'package:fluffychat/pages/chat_list/stories_header.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/profile_bottom_sheet.dart';
import 'package:fluffychat/widgets/public_room_bottom_sheet.dart';
import '../../utils/stream_extension.dart';
import '../../widgets/matrix.dart';
import 'spaces_hierarchy_proposal.dart';
class ChatListViewBody extends StatefulWidget {
final ChatListController controller;
const ChatListViewBody(this.controller, {Key? key}) : super(key: key);
@override
State<ChatListViewBody> createState() => _ChatListViewBodyState();
}
class _ChatListViewBodyState extends State<ChatListViewBody> {
// the matrix sync stream
late StreamSubscription _subscription;
// used to check the animation direction
String? _lastUserId;
SpacesEntry? _lastSpace;
@override
void initState() {
_subscription = Matrix.of(context)
.client
.onSync
.stream
.where((s) => s.hasRoomUpdate)
.rateLimit(const Duration(seconds: 1))
.listen((d) => setState(() {}));
super.initState();
}
@override
Widget build(BuildContext context) {
final reversed = !_animationReversed();
final roomSearchResult = widget.controller.roomSearchResult;
final userSearchResult = widget.controller.userSearchResult;
Widget child;
if (widget.controller.waitForFirstSync &&
Matrix.of(context).client.prevBatch != null) {
final rooms = widget.controller.activeSpacesEntry.getRooms(context);
final displayStoriesHeader = widget.controller.activeSpacesEntry
.shouldShowStoriesHeader(context) ||
rooms.isEmpty;
child = ListView.builder(
key: ValueKey(Matrix.of(context).client.userID.toString() +
widget.controller.activeSpaceId.toString() +
widget.controller.activeSpacesEntry.runtimeType.toString()),
controller: widget.controller.scrollController,
// add +1 space below in order to properly scroll below the spaces bar
itemCount: rooms.length + (displayStoriesHeader ? 2 : 1),
itemBuilder: (BuildContext context, int i) {
if (displayStoriesHeader) {
if (i == 0) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SpaceRoomListTopBar(widget.controller),
if (roomSearchResult != null) ...[
SearchTitle(
title: L10n.of(context)!.publicRooms,
icon: const Icon(Icons.explore_outlined),
),
AnimatedContainer(
height: roomSearchResult.chunk.isEmpty ? 0 : 106,
duration: const Duration(milliseconds: 250),
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: roomSearchResult.chunk.length,
itemBuilder: (context, i) => _SearchItem(
title: roomSearchResult.chunk[i].name ??
roomSearchResult
.chunk[i].canonicalAlias?.localpart ??
L10n.of(context)!.group,
avatar: roomSearchResult.chunk[i].avatarUrl,
onPressed: () => showModalBottomSheet(
context: context,
builder: (c) => PublicRoomBottomSheet(
roomAlias:
roomSearchResult.chunk[i].canonicalAlias ??
roomSearchResult.chunk[i].roomId,
outerContext: context,
chunk: roomSearchResult.chunk[i],
),
),
),
),
),
],
if (userSearchResult != null) ...[
SearchTitle(
title: L10n.of(context)!.users,
icon: const Icon(Icons.group_outlined),
),
AnimatedContainer(
height: userSearchResult.results.isEmpty ? 0 : 106,
duration: const Duration(milliseconds: 250),
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: userSearchResult.results.length,
itemBuilder: (context, i) => _SearchItem(
title: userSearchResult.results[i].displayName ??
userSearchResult.results[i].userId.localpart ??
L10n.of(context)!.unknownDevice,
avatar: userSearchResult.results[i].avatarUrl,
onPressed: () => showModalBottomSheet(
context: context,
builder: (c) => ProfileBottomSheet(
userId: userSearchResult.results[i].userId,
outerContext: context,
),
),
),
),
),
],
if (widget.controller.isSearchMode)
SearchTitle(
title: L10n.of(context)!.stories,
icon: const Icon(Icons.camera_alt_outlined),
),
StoriesHeader(
filter: widget.controller.searchController.text,
),
AnimatedContainer(
height: !widget.controller.isSearchMode &&
widget.controller.showChatBackupBanner
? 54
: 0,
duration: const Duration(milliseconds: 300),
clipBehavior: Clip.hardEdge,
curve: Curves.bounceInOut,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: CircleAvatar(
radius: Avatar.defaultSize / 2,
backgroundColor:
Theme.of(context).colorScheme.surfaceVariant,
foregroundColor:
Theme.of(context).colorScheme.onSurfaceVariant,
child: const Icon(Icons.enhanced_encryption_outlined),
),
title: Text(
(Matrix.of(context)
.client
.encryption
?.keyManager
.enabled ==
true)
? L10n.of(context)!.unlockOldMessages
: L10n.of(context)!.enableAutoBackups,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: widget.controller.firstRunBootstrapAction,
),
),
),
AnimatedContainer(
height: widget.controller.isTorBrowser ? 64 : 0,
duration: const Duration(milliseconds: 300),
clipBehavior: Clip.hardEdge,
curve: Curves.bounceInOut,
decoration: const BoxDecoration(),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(L10n.of(context)!.dehydrateTor),
subtitle: Text(L10n.of(context)!.dehydrateTorLong),
trailing: const Icon(Icons.chevron_right_outlined),
onTap: widget.controller.dehydrate,
),
),
),
if (widget.controller.isSearchMode)
SearchTitle(
title: L10n.of(context)!.chats,
icon: const Icon(Icons.chat_outlined),
),
if (rooms.isEmpty && !widget.controller.isSearchMode)
Column(
key: const ValueKey(null),
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Image.asset(
'assets/private_chat_wallpaper.png',
width: 160,
height: 160,
),
Center(
child: Text(
L10n.of(context)!.startYourFirstChat,
textAlign: TextAlign.start,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
),
const SizedBox(height: 16),
],
),
],
);
}
i--;
}
if (i >= rooms.length) {
return SpacesHierarchyProposals(
space: widget.controller.activeSpacesEntry.getSpace(context)?.id,
query: widget.controller.isSearchMode
? widget.controller.searchController.text
: null,
);
}
if (!rooms[i].displayname.toLowerCase().contains(
widget.controller.searchController.text.toLowerCase())) {
return Container();
}
return ChatListItem(
rooms[i],
selected: widget.controller.selectedRoomIds.contains(rooms[i].id),
onTap: widget.controller.selectMode == SelectMode.select
? () => widget.controller.toggleSelection(rooms[i].id)
: null,
onLongPress: () => widget.controller.toggleSelection(rooms[i].id),
activeChat: widget.controller.activeChat == rooms[i].id,
);
},
);
} else {
const dummyChatCount = 5;
final titleColor =
Theme.of(context).textTheme.bodyText1!.color!.withAlpha(100);
final subtitleColor =
Theme.of(context).textTheme.bodyText1!.color!.withAlpha(50);
child = ListView.builder(
itemCount: dummyChatCount,
itemBuilder: (context, i) => Opacity(
opacity: (dummyChatCount - i) / dummyChatCount,
child: ListTile(
leading: CircleAvatar(
backgroundColor: titleColor,
child: CircularProgressIndicator(
strokeWidth: 1,
color: Theme.of(context).textTheme.bodyText1!.color,
),
),
title: Row(
children: [
Expanded(
child: Container(
height: 14,
decoration: BoxDecoration(
color: titleColor,
borderRadius: BorderRadius.circular(3),
),
),
),
const SizedBox(width: 36),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
const SizedBox(width: 12),
Container(
height: 14,
width: 14,
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(14),
),
),
],
),
subtitle: Container(
decoration: BoxDecoration(
color: subtitleColor,
borderRadius: BorderRadius.circular(3),
),
height: 12,
margin: const EdgeInsets.only(right: 22),
),
),
),
);
}
return PageTransitionSwitcher(
reverse: reversed,
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.vertical,
fillColor: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
},
child: child,
);
}
@override
void dispose() {
_subscription.cancel();
super.dispose();
}
bool _animationReversed() {
bool reversed;
// in case the matrix id changes, check the indexOf the matrix id
final newClient = Matrix.of(context).client;
if (_lastUserId != newClient.userID) {
reversed = Matrix.of(context)
.currentBundle!
.indexWhere((element) => element!.userID == _lastUserId) <
Matrix.of(context)
.currentBundle!
.indexWhere((element) => element!.userID == newClient.userID);
}
// otherwise, the space changed...
else {
reversed = widget.controller.spacesEntries
.indexWhere((element) => element == _lastSpace) <
widget.controller.spacesEntries.indexWhere(
(element) => element == widget.controller.activeSpacesEntry);
}
_lastUserId = newClient.userID;
_lastSpace = widget.controller.activeSpacesEntry;
return reversed;
}
@override
void didUpdateWidget(covariant ChatListViewBody oldWidget) {
setState(() {});
super.didUpdateWidget(oldWidget);
}
}
class SpaceRoomListTopBar extends StatefulWidget {
final ChatListController controller;
const SpaceRoomListTopBar(this.controller, {Key? key}) : super(key: key);
@override
State<SpaceRoomListTopBar> createState() => _SpaceRoomListTopBarState();
}
class _SpaceRoomListTopBarState extends State<SpaceRoomListTopBar> {
bool _limitSize = true;
@override
Widget build(BuildContext context) {
if (widget.controller.activeSpacesEntry is SpaceSpacesEntry &&
!widget.controller.isSearchMode &&
(widget.controller.activeSpacesEntry as SpaceSpacesEntry)
.space
.topic
.isNotEmpty) {
return GestureDetector(
onTap: () => setState(() {
_limitSize = !_limitSize;
}),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: LinkText(
text: (widget.controller.activeSpacesEntry as SpaceSpacesEntry)
.space
.topic,
maxLines: _limitSize ? 3 : null,
linkStyle: const TextStyle(color: Colors.blueAccent),
textStyle: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyText2!.color,
),
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
),
),
const Divider(),
],
),
);
} else {
return Container();
}
}
}
class _SearchItem extends StatelessWidget {
final String title;
final Uri? avatar;
final void Function() onPressed;
const _SearchItem({
required this.title,
this.avatar,
required this.onPressed,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) => InkWell(
onTap: onPressed,
child: SizedBox(
width: 84,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Avatar(
mxContent: avatar,
name: title,
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
title,
maxLines: 2,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 12,
),
),
),
],
),
),
);
}