diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c19c28074..beda0f9f2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -224,4 +224,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 127d285aa3bfad2bbd68630172869bd951e40542 -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3 diff --git a/lib/global/formatters.dart b/lib/global/formatters.dart index 4428c001f..e3df88e92 100644 --- a/lib/global/formatters.dart +++ b/lib/global/formatters.dart @@ -83,20 +83,20 @@ String formatTimestamp({ final timestamp = DateTime.fromMillisecondsSinceEpoch(lastUpdateMillis); final sinceLastUpdate = DateTime.now().difference(timestamp); - final hourFormat = timeFormat == TimeFormat.hr24 ? 'H:mm' : 'h:mm'; + final hourFormat = timeFormat == TimeFormat.hr24 ? 'H:mm' : 'h:mm a'; if (sinceLastUpdate.inDays > 365) { return DateFormat( - showTime ? 'MMM d $hourFormat a' : 'MMM d yyyy', + showTime ? 'MMM d $hourFormat' : 'MMM d yyyy', ).format(timestamp); } else if (sinceLastUpdate.inDays > 6) { // Abbreviated month and day number - Jan 1 return DateFormat( - showTime ? 'MMM d $hourFormat a' : 'MMM d', + showTime ? 'MMM d $hourFormat' : 'MMM d', ).format(timestamp); } else if (sinceLastUpdate.inDays > 0) { // Abbreviated weekday - Fri - return DateFormat(showTime ? 'E $hourFormat a' : 'E').format(timestamp); + return DateFormat(showTime ? 'E $hourFormat' : 'E').format(timestamp); } else if (sinceLastUpdate.inHours > 0) { // Abbreviated hours since - 1h return '${sinceLastUpdate.inHours}h'; diff --git a/lib/store/auth/actions.dart b/lib/store/auth/actions.dart index 9de847fa6..1c5066820 100644 --- a/lib/store/auth/actions.dart +++ b/lib/store/auth/actions.dart @@ -10,7 +10,6 @@ import 'package:redux_thunk/redux_thunk.dart'; import 'package:syphon/context/auth.dart'; import 'package:syphon/context/storage.dart'; import 'package:syphon/context/types.dart'; -import 'package:syphon/global/algos.dart'; import 'package:syphon/global/libs/matrix/auth.dart'; import 'package:syphon/global/libs/matrix/errors.dart'; import 'package:syphon/global/libs/matrix/index.dart'; @@ -35,6 +34,7 @@ import 'package:syphon/store/rooms/actions.dart'; import 'package:syphon/store/search/actions.dart'; import 'package:syphon/store/settings/actions.dart'; import 'package:syphon/store/settings/devices-settings/model.dart'; +import 'package:syphon/store/settings/notification-settings/actions.dart'; import 'package:syphon/store/settings/notification-settings/remote/actions.dart'; import 'package:syphon/store/sync/actions.dart'; import 'package:syphon/store/sync/background/storage.dart'; diff --git a/lib/store/index.dart b/lib/store/index.dart index 6206dd22f..8812077e9 100644 --- a/lib/store/index.dart +++ b/lib/store/index.dart @@ -162,7 +162,7 @@ Future> initStore( shouldSave: cacheMiddleware, ); - // Finally load persisted store + // finally load persisted store try { // TODO: this is pretty hacky - merges availableUsers across stores if (existingUser) { diff --git a/lib/store/rooms/room/model.dart b/lib/store/rooms/room/model.dart index bcf0cba03..a83bb3144 100644 --- a/lib/store/rooms/room/model.dart +++ b/lib/store/rooms/room/model.dart @@ -1,7 +1,6 @@ import 'package:collection/collection.dart' show IterableExtension; import 'package:drift/drift.dart' as drift; import 'package:json_annotation/json_annotation.dart'; -import 'package:syphon/global/ids.dart'; import 'package:syphon/global/libs/matrix/constants.dart'; import 'package:syphon/global/print.dart'; import 'package:syphon/global/strings.dart'; @@ -16,11 +15,6 @@ import 'package:syphon/store/user/model.dart'; part 'model.g.dart'; -// TODO: convert to using Identifier wrapper class -class RoomId extends Identifier { - RoomId(id) : super(id: id); -} - class RoomPresets { static const private = 'private_chat'; static const privateTrusted = 'trusted_private_chat'; diff --git a/lib/store/rooms/room/types.dart b/lib/store/rooms/room/types.dart new file mode 100644 index 000000000..5908cff01 --- /dev/null +++ b/lib/store/rooms/room/types.dart @@ -0,0 +1,13 @@ +import 'package:syphon/global/ids.dart'; + +// TODO: convert to using Identifier wrapper class +class RoomId extends Identifier { + RoomId(id) : super(id: id); +} + +enum RoomType { + invite, + public, + direct, + group, +} diff --git a/lib/store/settings/actions.dart b/lib/store/settings/actions.dart index ab0db1612..02d76b567 100644 --- a/lib/store/settings/actions.dart +++ b/lib/store/settings/actions.dart @@ -2,9 +2,7 @@ import 'package:redux/redux.dart'; import 'package:redux_thunk/redux_thunk.dart'; import 'package:syphon/global/libs/matrix/auth.dart'; import 'package:syphon/global/libs/matrix/index.dart'; -import 'package:syphon/global/notifications.dart'; import 'package:syphon/global/print.dart'; -import 'package:syphon/global/strings.dart'; import 'package:syphon/global/themes.dart'; import 'package:syphon/global/values.dart'; import 'package:syphon/store/alerts/actions.dart'; @@ -13,9 +11,7 @@ import 'package:syphon/store/auth/credential/model.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/settings/devices-settings/model.dart'; import 'package:syphon/store/settings/models.dart'; -import 'package:syphon/store/settings/storage.dart'; import 'package:syphon/store/settings/theme-settings/model.dart'; -import 'package:syphon/store/sync/background/service.dart'; class SetThemeType { final ThemeType themeType; @@ -125,8 +121,6 @@ class ToggleRoomTypeBadges {} class ToggleMembershipEvents {} -class ToggleNotifications {} - class ToggleTypingIndicators {} class ToggleTimeFormat {} @@ -414,18 +408,21 @@ Future homeserverSupportsHiddenReadReceipts(Store store) async { final unstableFeatures = version['unstable_features']; - return unstableFeatures != null - && unstableFeatures.containsKey('org.matrix.msc2285') - && unstableFeatures['org.matrix.msc2285']; + return unstableFeatures != null && + unstableFeatures.containsKey('org.matrix.msc2285') && + unstableFeatures['org.matrix.msc2285']; } ThunkAction incrementReadReceipts() { return (Store store) async { - final readReceiptsIndex = ReadReceiptTypes.values.indexOf(store.state.settingsStore.readReceipts); + final readReceiptsIndex = + ReadReceiptTypes.values.indexOf(store.state.settingsStore.readReceipts); - final nextReceipt = ReadReceiptTypes.values[(readReceiptsIndex + 1) % ReadReceiptTypes.values.length]; + final nextReceipt = + ReadReceiptTypes.values[(readReceiptsIndex + 1) % ReadReceiptTypes.values.length]; - if (nextReceipt != ReadReceiptTypes.Hidden) { //short-out + if (nextReceipt != ReadReceiptTypes.Hidden) { + //short-out return store.dispatch(SetReadReceipts( readReceipts: nextReceipt, )); @@ -438,8 +435,8 @@ ThunkAction incrementReadReceipts() { } return store.dispatch(SetReadReceipts( - readReceipts: ReadReceiptTypes.values[(readReceiptsIndex + 2) % - ReadReceiptTypes.values.length], + readReceipts: + ReadReceiptTypes.values[(readReceiptsIndex + 2) % ReadReceiptTypes.values.length], )); }; } @@ -497,61 +494,3 @@ ThunkAction toggleMembershipEvents() { store.dispatch(ToggleMembershipEvents()); }; } - -ThunkAction toggleNotifications() { - return (Store store) async { - if (globalNotificationPluginInstance == null) { - return; - } - - final permitted = await promptNativeNotificationsRequest( - pluginInstance: globalNotificationPluginInstance!, - ); - - if (!permitted) { - return; - } - - store.dispatch(ToggleNotifications()); - - final enabled = store.state.settingsStore.notificationSettings.enabled; - - if (enabled) { - store.dispatch(startNotifications()); - } else { - store.dispatch(stopNotifications()); - } - }; -} - -ThunkAction startNotifications() { - return (Store store) async { - await BackgroundSync.init(); - - final Map roomNames = store.state.roomStore.rooms.map( - (roomId, room) => MapEntry(roomId, room.name), - ); - - await BackgroundSync.start( - roomNames: roomNames, - protocol: store.state.authStore.protocol, - lastSince: store.state.syncStore.lastSince, - currentUser: store.state.authStore.currentUser, - settings: store.state.settingsStore.notificationSettings, - ); - - showBackgroundServiceNotification( - notificationId: BackgroundSync.service_id, - pluginInstance: globalNotificationPluginInstance!, - ); - }; -} - -ThunkAction stopNotifications() { - return (Store store) async { - BackgroundSync.stop(); - dismissAllNotifications( - pluginInstance: globalNotificationPluginInstance, - ); - }; -} diff --git a/lib/store/settings/chat-settings/chat-lists/actions.dart b/lib/store/settings/chat-settings/chat-lists/actions.dart new file mode 100644 index 000000000..599ef9684 --- /dev/null +++ b/lib/store/settings/chat-settings/chat-lists/actions.dart @@ -0,0 +1,41 @@ +import 'package:redux/redux.dart'; +import 'package:redux_thunk/redux_thunk.dart'; + +import 'package:syphon/store/index.dart'; +import 'package:syphon/store/settings/actions.dart'; +import 'package:syphon/store/settings/chat-settings/chat-lists/model.dart'; + +class CreateChatList { + const CreateChatList(); +} + +class UpdateChatList { + final ChatList? list; + const UpdateChatList({this.list}); +} + +class DeleteChatList { + final ChatList? list; + const DeleteChatList({this.list}); +} + +ThunkAction createChatList() { + return (Store store) async { + store.dispatch(CreateChatList()); + }; +} + +ThunkAction updateChatList(ChatList? list) { + return (Store store) async { + store.dispatch(UpdateChatList(list: list)); + }; +} + +ThunkAction removeChatList({String? roomId, int? color}) { + return (Store store) async { + store.dispatch(SetRoomPrimaryColor( + roomId: roomId, + color: color, + )); + }; +} diff --git a/lib/store/settings/chat-settings/chat-lists/model.dart b/lib/store/settings/chat-settings/chat-lists/model.dart index 29567b9f7..a5014c281 100644 --- a/lib/store/settings/chat-settings/chat-lists/model.dart +++ b/lib/store/settings/chat-settings/chat-lists/model.dart @@ -13,24 +13,36 @@ enum SortOrder { @JsonSerializable() class ChatList extends Equatable { final String id; - final SortOrder order; + final String name; + final String symbol; // emoji for now + // List final List roomIds; + // order is used only under the "Custom" sort order + // Map + final Map order; + + final int position; // global position in home list const ChatList({ required this.id, - this.order = SortOrder.Latest, // millis + this.name = 'New List', + this.symbol = '', + this.order = const {}, this.roomIds = const [], + this.position = 0, }); @override List get props => [ id, + name, + symbol, order, roomIds, + position, ]; Map toJson() => _$ChatListToJson(this); - factory ChatList.fromJson(Map json) => - _$ChatListFromJson(json); + factory ChatList.fromJson(Map json) => _$ChatListFromJson(json); } diff --git a/lib/store/settings/notification-settings/actions.dart b/lib/store/settings/notification-settings/actions.dart index bf666ac5a..462007dfb 100644 --- a/lib/store/settings/notification-settings/actions.dart +++ b/lib/store/settings/notification-settings/actions.dart @@ -1,10 +1,13 @@ import 'package:redux/redux.dart'; import 'package:redux_thunk/redux_thunk.dart'; +import 'package:syphon/global/notifications.dart'; import 'package:syphon/store/index.dart'; -import 'package:syphon/store/settings/actions.dart'; import 'package:syphon/store/settings/notification-settings/model.dart'; import 'package:syphon/store/settings/notification-settings/options/types.dart'; +import 'package:syphon/store/sync/background/service.dart'; + +class ToggleNotifications {} class MuteChatNotifications { final String roomId; @@ -22,6 +25,64 @@ class SetNotificationSettings { SetNotificationSettings({required this.settings}); } +ThunkAction toggleNotifications() { + return (Store store) async { + if (globalNotificationPluginInstance == null) { + return; + } + + final permitted = await promptNativeNotificationsRequest( + pluginInstance: globalNotificationPluginInstance!, + ); + + if (!permitted) { + return; + } + + store.dispatch(ToggleNotifications()); + + final enabled = store.state.settingsStore.notificationSettings.enabled; + + if (enabled) { + store.dispatch(startNotifications()); + } else { + store.dispatch(stopNotifications()); + } + }; +} + +ThunkAction startNotifications() { + return (Store store) async { + await BackgroundSync.init(); + + final Map roomNames = store.state.roomStore.rooms.map( + (roomId, room) => MapEntry(roomId, room.name), + ); + + await BackgroundSync.start( + roomNames: roomNames, + protocol: store.state.authStore.protocol, + lastSince: store.state.syncStore.lastSince, + currentUser: store.state.authStore.currentUser, + settings: store.state.settingsStore.notificationSettings, + ); + + showBackgroundServiceNotification( + notificationId: BackgroundSync.service_id, + pluginInstance: globalNotificationPluginInstance!, + ); + }; +} + +ThunkAction stopNotifications() { + return (Store store) async { + BackgroundSync.stop(); + dismissAllNotifications( + pluginInstance: globalNotificationPluginInstance, + ); + }; +} + /// /// Mute Chat Notifications /// @@ -34,8 +95,7 @@ ThunkAction muteChatNotifications({ }) { return (Store store) async { final settings = store.state.settingsStore.notificationSettings; - final options = - Map.from(settings.notificationOptions); + final options = Map.from(settings.notificationOptions); options.putIfAbsent(roomId, () => NotificationOptions()); @@ -64,8 +124,7 @@ ThunkAction toggleChatNotifications({ }) { return (Store store) async { final settings = store.state.settingsStore.notificationSettings; - final options = - Map.from(settings.notificationOptions); + final options = Map.from(settings.notificationOptions); options.putIfAbsent(roomId, () => NotificationOptions()); @@ -91,8 +150,7 @@ ThunkAction incrementToggleType() { final settings = store.state.settingsStore.notificationSettings; final index = ToggleType.values.indexOf(settings.toggleType); - final toggleType = - ToggleType.values[(index + 1) % ToggleType.values.length]; + final toggleType = ToggleType.values[(index + 1) % ToggleType.values.length]; store.dispatch(SetNotificationSettings( settings: settings.copyWith(toggleType: toggleType), diff --git a/lib/store/settings/reducer.dart b/lib/store/settings/reducer.dart index f0a90c71d..3b16335d4 100644 --- a/lib/store/settings/reducer.dart +++ b/lib/store/settings/reducer.dart @@ -1,7 +1,10 @@ import 'package:syphon/global/https.dart'; +import 'package:syphon/store/settings/chat-settings/chat-lists/actions.dart'; +import 'package:syphon/store/settings/chat-settings/chat-lists/model.dart'; import 'package:syphon/store/settings/chat-settings/model.dart'; import 'package:syphon/store/settings/notification-settings/actions.dart'; import 'package:syphon/store/settings/proxy-settings/actions.dart'; +import 'package:uuid/uuid.dart'; import './actions.dart'; import './state.dart'; @@ -122,6 +125,14 @@ SettingsStore settingsReducer([SettingsStore state = const SettingsStore(), dyna return state.copyWith( autoDownloadEnabled: !state.autoDownloadEnabled, ); + case CreateChatList: + final chatListsUpdated = List.from(state.chatLists); + + chatListsUpdated.add(ChatList(id: Uuid().v4())); + + return state.copyWith( + chatLists: chatListsUpdated, + ); case ToggleProxy: final _state = state.copyWith( proxySettings: state.proxySettings.copyWith(enabled: !state.proxySettings.enabled), diff --git a/lib/store/settings/state.dart b/lib/store/settings/state.dart index 93c7ec15a..298ea4645 100644 --- a/lib/store/settings/state.dart +++ b/lib/store/settings/state.dart @@ -33,7 +33,7 @@ class SettingsStore extends Equatable { final int syncInterval; final int syncPollTimeout; - final SortOrder globalSortOrder; + final SortOrder sortOrderGlobal; final List chatLists; final ReadReceiptTypes readReceipts; @@ -52,7 +52,7 @@ class SettingsStore extends Equatable { this.syncInterval = 2000, // millis this.syncPollTimeout = 10000, // millis this.chatLists = const [], - this.globalSortOrder = SortOrder.Latest, + this.sortOrderGlobal = SortOrder.Latest, this.enterSendEnabled = false, this.autocorrectEnabled = false, this.suggestionsEnabled = false, diff --git a/lib/views/home/home-screen.dart b/lib/views/home/home-screen.dart index 2f256eb78..71d47e018 100644 --- a/lib/views/home/home-screen.dart +++ b/lib/views/home/home-screen.dart @@ -11,6 +11,7 @@ import 'package:syphon/global/colours.dart'; import 'package:syphon/global/dimensions.dart'; import 'package:syphon/global/formatters.dart'; import 'package:syphon/global/libs/matrix/constants.dart'; +import 'package:syphon/global/print.dart'; import 'package:syphon/global/strings.dart'; import 'package:syphon/global/values.dart'; import 'package:syphon/global/weburl.dart'; @@ -22,6 +23,8 @@ import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/rooms/room/selectors.dart'; import 'package:syphon/store/rooms/selectors.dart'; import 'package:syphon/store/search/actions.dart'; +import 'package:syphon/store/settings/chat-settings/chat-lists/actions.dart'; +import 'package:syphon/store/settings/chat-settings/chat-lists/model.dart'; import 'package:syphon/store/settings/chat-settings/model.dart'; import 'package:syphon/store/settings/chat-settings/selectors.dart'; import 'package:syphon/store/settings/theme-settings/model.dart'; @@ -34,6 +37,7 @@ import 'package:syphon/views/home/chat/chat-screen.dart'; import 'package:syphon/views/navigation.dart'; import 'package:syphon/views/widgets/appbars/appbar-avatar.dart'; import 'package:syphon/views/widgets/appbars/appbar-search.dart'; +import 'package:syphon/views/widgets/avatars/avatar-badge.dart'; import 'package:syphon/views/widgets/avatars/avatar.dart'; import 'package:syphon/views/widgets/containers/fabs/fab-bar-expanding.dart'; import 'package:syphon/views/widgets/containers/fabs/fab-circle-expanding.dart'; @@ -43,7 +47,7 @@ import 'package:syphon/views/widgets/dialogs/dialog-confirm.dart'; import 'package:syphon/views/widgets/dialogs/dialog-options.dart'; import 'package:syphon/views/widgets/loader/index.dart'; -enum Options { newGroup, markAllRead, inviteFriends, settings, licenses, help } +enum Options { newGroup, markAllRead, inviteFriends, createList, settings, licenses, help } class HomeScreen extends StatefulWidget { const HomeScreen({Key? key}) : super(key: key); @@ -62,11 +66,96 @@ class HomeState extends State { final fabKeyCircle = GlobalKey(); bool searching = false; + bool organizing = false; String searchText = ''; Map selectedChats = {}; Map chatColorCache = {}; - @protected + isAllDirect(Map selectedChats) { + return selectedChats.values.every((chat) => chat.direct); + } + + selectActionAlignment(_Props props) { + if (props.fabLocation == MainFabLocation.Left) { + return Alignment.bottomLeft; + } + + return Alignment.bottomRight; + } + + selectActionLocation(_Props props) { + if (props.fabLocation == MainFabLocation.Left) { + return FloatingActionButtonLocation.startFloat; + } + + return FloatingActionButtonLocation.endFloat; + } + + onDismissMessageOptions() { + setState(() { + selectedChats = {}; + }); + } + + onCreateList() { + final store = StoreProvider.of(context); + store.dispatch(createChatList()); + } + + onToggleSearch() { + setState(() { + searching = !searching; + searchText = ''; + }); + } + + onSearch(_Props props, String text) { + final store = StoreProvider.of(context); + setState(() { + searchText = text; + }); + + if (text.isEmpty) { + return store.dispatch(clearSearchResults()); + } + + store.dispatch(searchMessages(text)); + } + + onSelectChat(Room room, String chatName) { + final store = StoreProvider.of(context); + + if (selectedChats.isNotEmpty) { + return onToggleRoomOptions(room: room); + } + + Navigator.pushNamed( + context, + Routes.chat, + arguments: ChatScreenArguments(roomId: room.id, title: chatName), + ); + + Timer(Duration(milliseconds: 500), () { + setState(() { + searching = false; + selectedChats = {}; + }); + store.dispatch(clearSearchResults()); + }); + } + + onSelectAll(_Props props) { + if (selectedChats.values.toSet().containsAll(props.rooms)) { + setState(() { + selectedChats = {}; + }); + } else { + setState(() { + selectedChats.addAll(Map.fromEntries(props.rooms.map((e) => MapEntry(e.id, e)))); + }); + } + } + onToggleRoomOptions({required Room room}) { if (searching) { onToggleSearch(); @@ -80,12 +169,12 @@ class HomeState extends State { selectedChats.remove(room.id); }); } - } - - onDismissMessageOptions() { - setState(() { - selectedChats = {}; - }); + if (selectedChats.isEmpty) { + log.debug('organization $organizing'); + setState(() { + organizing = false; + }); + } } onSelectHelp(_Props props) async { @@ -197,66 +286,22 @@ class HomeState extends State { ); } - onToggleSearch() { - setState(() { - searching = !searching; - searchText = ''; - }); - } - - onSearch(_Props props, String text) { - final store = StoreProvider.of(context); - setState(() { - searchText = text; - }); - - if (text.isEmpty) { - return store.dispatch(clearSearchResults()); - } - - store.dispatch(searchMessages(text)); - } - - onSelectChat(Room room, String chatName) { - final store = StoreProvider.of(context); + Widget buildActionFab(_Props props) { + final fabType = props.fabType; - if (selectedChats.isNotEmpty) { - return onToggleRoomOptions(room: room); + if (fabType == MainFabType.Bar) { + return FabBarExpanding( + alignment: selectActionAlignment(props), + ); } - Navigator.pushNamed( - context, - Routes.chat, - arguments: ChatScreenArguments(roomId: room.id, title: chatName), + return FabRing( + fabKey: fabKeyRing, + alignment: selectActionAlignment(props), ); - - Timer(Duration(milliseconds: 500), () { - setState(() { - searching = false; - selectedChats = {}; - }); - store.dispatch(clearSearchResults()); - }); - } - - onSelectAll(_Props props) { - if (selectedChats.values.toSet().containsAll(props.rooms)) { - setState(() { - selectedChats = {}; - }); - } else { - setState(() { - selectedChats.addAll(Map.fromEntries(props.rooms.map((e) => MapEntry(e.id, e)))); - }); - } - } - - bool isAllDirect(Map selectedChats) { - return selectedChats.values.every((chat) => chat.direct); } - @protected - Widget buildAppBarRoomOptions({required BuildContext context, required _Props props}) => AppBar( + Widget buildAppBarChatOptions({required BuildContext context, required _Props props}) => AppBar( backgroundColor: Color(Colours.greyDefault), automaticallyImplyLeading: false, titleSpacing: 0.0, @@ -329,7 +374,6 @@ class HomeState extends State { ], ); - @protected Widget buildAppBar({required BuildContext context, required _Props props}) { final assetColor = computeContrastColorText( Theme.of(context).appBarTheme.backgroundColor, @@ -380,6 +424,9 @@ class HomeState extends State { case Options.markAllRead: props.onMarkAllRead(); break; + case Options.createList: + onCreateList(); + break; case Options.settings: Navigator.pushNamed(context, Routes.settings); break; @@ -404,6 +451,11 @@ class HomeState extends State { enabled: false, child: Text(Strings.buttonTextInvite), ), + PopupMenuItem( + value: Options.createList, + enabled: true, + child: Text('Create List'), + ), PopupMenuItem( value: Options.settings, child: Text(Strings.buttonTextSettings), @@ -418,8 +470,204 @@ class HomeState extends State { ); } - @protected - Widget buildChatList(BuildContext context, _Props props) { + Widget buildChatListHeader(BuildContext context, _Props props, ChatList list) { + final width = MediaQuery.of(context).size.width; + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + + return Container( + // if selected, color seperately + constraints: BoxConstraints(maxWidth: width, maxHeight: 80), + decoration: BoxDecoration(color: backgroundColor), + padding: EdgeInsets.symmetric(vertical: 8).add(Dimensions.appPaddingHorizontal), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(right: 12), + // TODO: show color dot or emoji icon based on isEmpty for symbol + child: ClipRRect( + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondary, + border: Border.all( + color: Colors.white, + ), + borderRadius: BorderRadius.circular(Dimensions.badgeAvatarSize), + ), + width: Dimensions.badgeAvatarSize, + height: Dimensions.badgeAvatarSize, + margin: EdgeInsets.only(left: 4), + child: Text( + list.symbol, + style: TextStyle( + fontSize: Dimensions.iconSizeMini, + color: Colors.white, + ), + ), + ), + ), + ), + Flexible( + flex: 1, + fit: FlexFit.tight, + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + list.name, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + // Icon( + // Icons.keyboard_arrow_down, + // color: Theme.of(context).colorScheme.secondary, + // ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget buildChatRow( + BuildContext context, + _Props props, + Room room, { + required String chatName, + required Color chatColor, + }) { + final width = MediaQuery.of(context).size.width; + final messages = props.messages[room.id] ?? const []; + final decrypted = props.decrypted[room.id] ?? const []; + + final messageLatest = latestMessage(messages, room: room, decrypted: decrypted); + final preview = formatPreview(room: room, message: messageLatest); + + final isNewMessage = messageLatest != null && + room.lastRead < messageLatest.timestamp && + messageLatest.sender != props.currentUser.userId; + + var backgroundColor; + var textStyle = TextStyle(); + + // highlight selected rooms if necessary + if (selectedChats.isNotEmpty) { + if (!selectedChats.containsKey(room.id)) { + backgroundColor = Theme.of(context).scaffoldBackgroundColor; + } else { + backgroundColor = Theme.of(context).primaryColor.withAlpha(128); + } + } + + // show draft inidicator if it's an empty room + if (room.drafting || messages.isEmpty) { + textStyle = TextStyle(fontStyle: FontStyle.italic); + } + + if (messages.isNotEmpty && messageLatest != null) { + // it has undecrypted message contained within + if (messageLatest.type == EventTypes.encrypted && messageLatest.body!.isEmpty) { + textStyle = TextStyle(fontStyle: FontStyle.italic); + } + + if (messageLatest.body == null || messageLatest.body!.isEmpty) { + textStyle = TextStyle(fontStyle: FontStyle.italic); + } + + // display message as being 'unread' + if (isNewMessage) { + textStyle = textStyle.copyWith( + color: Theme.of(context).textTheme.bodyText1!.color, + fontWeight: FontWeight.w500, + ); + } + } + + return Container( + // if selected, color seperately + constraints: BoxConstraints(maxWidth: width), + decoration: BoxDecoration(color: backgroundColor), + padding: EdgeInsets.symmetric( + vertical: Theme.of(context).textTheme.subtitle1!.fontSize!, + ).add(Dimensions.appPaddingHorizontal), + child: Flex( + direction: Axis.horizontal, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + margin: const EdgeInsets.only(right: 12), + child: Stack( + children: [ + Avatar( + uri: room.avatarUri, + size: Dimensions.avatarSizeMin, + alt: formatRoomInitials(room: room), + background: chatColor, + ), + AvatarBadge( + indicator: isNewMessage, + invite: props.roomTypeBadgesEnabled && room.invite, + group: props.roomTypeBadgesEnabled && room.type == 'group', + public: props.roomTypeBadgesEnabled && room.type == 'public', + unencrypted: !room.encryptionEnabled, + ), + ], + ), + ), + Flexible( + flex: 1, + fit: FlexFit.tight, + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + chatName, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyText1, + ), + ), + Text( + formatTimestamp(lastUpdateMillis: room.lastUpdate), + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w100), + ), + ], + ), + Text( + preview, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.caption!.merge(textStyle), + ), + ], + ), + ), + ], + ), + ); + } + + Widget buildChatListAll(BuildContext context, _Props props) { final store = StoreProvider.of(context); final rooms = props.rooms; final label = props.syncing ? Strings.labelSyncingChats : Strings.labelMessagesEmpty; @@ -456,259 +704,75 @@ class HomeState extends State { ); } - return ListView.builder( - scrollDirection: Axis.vertical, - itemCount: noSearchResults ? 0 : rooms.length, - itemBuilder: (BuildContext context, int index) { - final room = rooms[index]; - final messages = props.messages[room.id] ?? const []; - final decrypted = props.decrypted[room.id] ?? const []; - - final messageLatest = latestMessage(messages, room: room, decrypted: decrypted); - final preview = formatPreview(room: room, message: messageLatest); - final chatName = room.name ?? ''; - final newMessage = messageLatest != null && - room.lastRead < messageLatest.timestamp && - messageLatest.sender != props.currentUser.userId; - - var backgroundColor; - var textStyle = TextStyle(); - final chatColor = selectChatColor(store, room.id); - - // highlight selected rooms if necessary - if (selectedChats.isNotEmpty) { - if (!selectedChats.containsKey(room.id)) { - backgroundColor = Theme.of(context).scaffoldBackgroundColor; - } else { - backgroundColor = Theme.of(context).primaryColor.withAlpha(128); - } - } - - // show draft inidicator if it's an empty room - if (room.drafting || messages.isEmpty) { - textStyle = TextStyle(fontStyle: FontStyle.italic); - } - - if (messages.isNotEmpty && messageLatest != null) { - // it has undecrypted message contained within - if (messageLatest.type == EventTypes.encrypted && messageLatest.body!.isEmpty) { - textStyle = TextStyle(fontStyle: FontStyle.italic); - } - - if (messageLatest.body == null || messageLatest.body!.isEmpty) { - textStyle = TextStyle(fontStyle: FontStyle.italic); - } - - // display message as being 'unread' - if (newMessage) { - textStyle = textStyle.copyWith( - color: Theme.of(context).textTheme.bodyText1!.color, - fontWeight: FontWeight.w500, - ); - } - } + itemBuilder(BuildContext context, int index) { + final room = rooms[index]; + final chatName = room.name ?? ''; + final chatColor = selectChatColor(store, room.id); + + final listItem = buildChatRow( + context, + props, + room, + chatColor: chatColor, + chatName: chatName, + ); - // GestureDetector w/ animation + if (selectedChats.isNotEmpty) { return InkWell( + key: Key(room.id), onTap: () => onSelectChat(room, chatName), - onLongPress: () => onToggleRoomOptions(room: room), - child: Container( - decoration: BoxDecoration( - color: backgroundColor, // if selected, color seperately - ), - padding: EdgeInsets.symmetric( - vertical: Theme.of(context).textTheme.subtitle1!.fontSize!, - ).add(Dimensions.appPaddingHorizontal), - child: Flex( - direction: Axis.horizontal, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - margin: const EdgeInsets.only(right: 12), - child: Stack( - children: [ - Avatar( - uri: room.avatarUri, - size: Dimensions.avatarSizeMin, - alt: formatRoomInitials(room: room), - background: chatColor, - ), - Visibility( - visible: !room.encryptionEnabled, - child: Positioned( - bottom: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular( - Dimensions.badgeAvatarSize, - ), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.lock_open, - color: Theme.of(context).iconTheme.color, - size: Dimensions.iconSizeMini, - ), - ), - ), - ), - ), - Visibility( - visible: props.roomTypeBadgesEnabled && room.invite, - child: Positioned( - bottom: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.mail_outline, - color: Theme.of(context).iconTheme.color, - size: Dimensions.iconSizeMini, - ), - ), - ), - ), - ), - Visibility( - visible: newMessage, - child: Positioned( - top: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSizeSmall, - height: Dimensions.badgeAvatarSizeSmall, - color: Theme.of(context).colorScheme.secondary, - ), - ), - ), - ), - Visibility( - visible: - props.roomTypeBadgesEnabled && room.type == 'group' && !room.invite, - child: Positioned( - right: 0, - bottom: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.group, - color: Theme.of(context).iconTheme.color, - size: Dimensions.badgeAvatarSizeSmall, - ), - ), - ), - ), - ), - Visibility( - visible: - props.roomTypeBadgesEnabled && room.type == 'public' && !room.invite, - child: Positioned( - right: 0, - bottom: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.public, - color: Theme.of(context).iconTheme.color, - size: Dimensions.badgeAvatarSize, - ), - ), - ), - ), - ), - ], - ), - ), - Flexible( - flex: 1, - fit: FlexFit.tight, - child: Flex( - direction: Axis.vertical, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - chatName, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyText1, - ), - ), - Text( - formatTimestamp(lastUpdateMillis: room.lastUpdate), - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w100), - ), - ], - ), - Text( - preview, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.caption!.merge( - textStyle, - ), - ), - ], - ), - ), - ], - ), - ), + onLongPress: () { + if (!organizing) { + log.debug('[organizing] $organizing'); + setState(() { + organizing = true; + }); + } + }, + child: listItem, ); - }, - ); - } + } - selectActionAlignment(_Props props) { - if (props.fabLocation == MainFabLocation.Left) { - return Alignment.bottomLeft; + // GestureDetector w/ animation + return InkWell( + onTap: () => onSelectChat(room, chatName), + onLongPress: () => onToggleRoomOptions(room: room), + child: listItem, + ); } - return Alignment.bottomRight; - } - - buildActionFab(_Props props) { - final fabType = props.fabType; - - if (fabType == MainFabType.Bar) { - return FabBarExpanding( - alignment: selectActionAlignment(props), + if (organizing && selectedChats.isNotEmpty) { + return ReorderableListView.builder( + scrollDirection: Axis.vertical, + itemCount: noSearchResults ? 0 : rooms.length, + itemBuilder: itemBuilder, + onReorder: (int oldIndex, int newIndex) { + log.debug('$oldIndex $newIndex'); + }, ); } - return FabRing( - fabKey: fabKeyRing, - alignment: selectActionAlignment(props), - ); - } + return ListView.separated( + scrollDirection: Axis.vertical, + itemCount: noSearchResults ? 0 : rooms.length, + separatorBuilder: (BuildContext context, int index) { + final chatListNext = props.chatLists.where((list) => list.position == index).toList(); - selectActionLocation(_Props props) { - if (props.fabLocation == MainFabLocation.Left) { - return FloatingActionButtonLocation.startFloat; - } + if (chatListNext.isEmpty) { + return Container(); + } - return FloatingActionButtonLocation.endFloat; + return ListView( + shrinkWrap: true, + children: chatListNext + .map( + (list) => buildChatListHeader(context, props, list), + ) + .toList(), + ); + }, + itemBuilder: itemBuilder, + ); } @override @@ -722,7 +786,7 @@ class HomeState extends State { ); if (selectedChats.isNotEmpty) { - currentAppBar = buildAppBarRoomOptions( + currentAppBar = buildAppBarChatOptions( props: props, context: context, ); @@ -764,10 +828,7 @@ class HomeState extends State { ), GestureDetector( onTap: onDismissMessageOptions, - child: buildChatList( - context, - props, - ), + child: buildChatListAll(context, props), ), ], ), @@ -794,6 +855,7 @@ class _Props extends Equatable { final MainFabType fabType; final MainFabLocation fabLocation; final List searchMessages; + final List chatLists; final Map chatSettings; final Map> messages; final Map> decrypted; @@ -801,7 +863,6 @@ class _Props extends Equatable { final Function onLeaveChat; final Function onDeleteChat; final Function onArchiveChat; - final Function onSelectHelp; final Function onMarkAllRead; final Function onFetchSyncForced; @@ -814,6 +875,7 @@ class _Props extends Equatable { required this.unauthed, required this.messages, required this.decrypted, + required this.chatLists, required this.currentUser, required this.chatSettings, required this.searchMessages, @@ -822,7 +884,6 @@ class _Props extends Equatable { required this.roomTypeBadgesEnabled, required this.onLeaveChat, required this.onDeleteChat, - required this.onSelectHelp, required this.onArchiveChat, required this.onMarkAllRead, required this.onFetchSyncForced, @@ -837,6 +898,7 @@ class _Props extends Equatable { searching, offline, unauthed, + chatLists, currentUser, chatSettings, roomTypeBadgesEnabled, @@ -869,6 +931,7 @@ class _Props extends Equatable { currentUser: store.state.authStore.user, roomTypeBadgesEnabled: store.state.settingsStore.roomTypeBadgesEnabled, chatSettings: store.state.settingsStore.chatSettings, + chatLists: store.state.settingsStore.chatLists, onMarkAllRead: () { store.dispatch(markRoomsReadAll()); }, @@ -887,6 +950,5 @@ class _Props extends Equatable { ); return Future(() => true); }, - onSelectHelp: () async {}, ); } diff --git a/lib/views/home/settings/advanced-settings-screen.dart b/lib/views/home/settings/advanced-settings-screen.dart index 2f086dc0d..db2ac12e7 100644 --- a/lib/views/home/settings/advanced-settings-screen.dart +++ b/lib/views/home/settings/advanced-settings-screen.dart @@ -1,23 +1,19 @@ -import 'dart:io'; - import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_redux/flutter_redux.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:package_info/package_info.dart'; import 'package:redux/redux.dart'; import 'package:syphon/global/colours.dart'; import 'package:syphon/global/dimensions.dart'; import 'package:syphon/global/notifications.dart'; -import 'package:syphon/global/print.dart'; import 'package:syphon/global/strings.dart'; import 'package:syphon/global/values.dart'; -import 'package:syphon/store/crypto/actions.dart'; import 'package:syphon/store/crypto/keys/actions.dart'; import 'package:syphon/store/index.dart'; import 'package:syphon/store/settings/actions.dart'; +import 'package:syphon/store/settings/notification-settings/actions.dart'; import 'package:syphon/store/sync/actions.dart'; import 'package:syphon/store/sync/background/service.dart'; import 'package:syphon/store/user/model.dart'; diff --git a/lib/views/widgets/avatars/avatar-badge.dart b/lib/views/widgets/avatars/avatar-badge.dart index 4bf4661d0..e2405af84 100644 --- a/lib/views/widgets/avatars/avatar-badge.dart +++ b/lib/views/widgets/avatars/avatar-badge.dart @@ -14,97 +14,100 @@ class AvatarBadge extends StatelessWidget { this.public = false, this.group = false, this.invite = false, - this.encryptionEnabled = false, + this.indicator = false, + this.unencrypted = false, }) : super(key: key); final bool public; final bool group; final bool invite; - final bool encryptionEnabled; + final bool indicator; + final bool unencrypted; + + Widget buildIndicator(BuildContext context) { + return Positioned( + top: 0, + right: 0, + child: ClipRRect( + borderRadius: BorderRadius.circular( + Dimensions.badgeAvatarSize, + ), + child: Container( + width: Dimensions.badgeAvatarSizeSmall, + height: Dimensions.badgeAvatarSizeSmall, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ); + } + + Widget buildBadge(BuildContext context, Icon icon, {Color? color}) { + return Positioned( + bottom: 0, + right: 0, + child: ClipRRect( + borderRadius: BorderRadius.circular( + Dimensions.badgeAvatarSize, + ), + child: Container( + width: Dimensions.badgeAvatarSize, + height: Dimensions.badgeAvatarSize, + color: color ?? Theme.of(context).scaffoldBackgroundColor, + child: icon, + ), + ), + ); + } @override Widget build(BuildContext context) => StoreConnector( distinct: true, converter: (Store store) => _Props.mapStateToProps(store), builder: (context, props) { - if (encryptionEnabled) { - return Positioned( - bottom: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular( - Dimensions.badgeAvatarSize, - ), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.lock_open, - color: Theme.of(context).iconTheme.color, - size: Dimensions.iconSizeMini, - ), - ), + if (indicator) { + return buildIndicator(context); + } + + if (unencrypted) { + return buildBadge( + context, + Icon( + Icons.lock_open, + color: Theme.of(context).iconTheme.color, + size: Dimensions.iconSizeMini, ), ); } if (invite) { - return Positioned( - bottom: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.mail_outline, - color: Theme.of(context).iconTheme.color, - size: Dimensions.iconSizeMini, - ), - ), + return buildBadge( + context, + Icon( + Icons.mail_outline, + color: Theme.of(context).iconTheme.color, + size: Dimensions.iconSizeMini, ), ); } if (group) { - return Positioned( - bottom: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.group, - color: Theme.of(context).iconTheme.color, - size: Dimensions.badgeAvatarSizeSmall, - ), - ), + return buildBadge( + context, + Icon( + Icons.group, + color: Theme.of(context).iconTheme.color, + size: Dimensions.badgeAvatarSizeSmall, ), ); } if (public) { - return Positioned( - bottom: 0, - right: 0, - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Container( - width: Dimensions.badgeAvatarSize, - height: Dimensions.badgeAvatarSize, - color: Theme.of(context).scaffoldBackgroundColor, - child: Icon( - Icons.public, - color: Theme.of(context).iconTheme.color, - size: Dimensions.badgeAvatarSize, - ), - ), + return buildBadge( + context, + Icon( + Icons.public, + color: Theme.of(context).iconTheme.color, + size: Dimensions.badgeAvatarSize, ), ); } diff --git a/pubspec.lock b/pubspec.lock index e6556d5c2..c0b914c5e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -956,6 +956,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.4.0" + reorderables: + dependency: "direct main" + description: + name: reorderables + url: "https://pub.dartlang.org" + source: hosted + version: "0.4.3" sembast: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 001e62a22..9351386c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -104,6 +104,7 @@ dependencies: flutter_speed_dial: ^4.2.0 fab_circular_menu: ^1.0.2 flutter_screen_lock: ^4.0.0 + reorderables: 0.4.3 photo_view: 0.13.0 # window_utils: 1.0.2 # TODO: desktop only window sizing