From 861dbcfa443f0616c8be661e057e7b9922f13d8a Mon Sep 17 00:00:00 2001 From: jld3103 Date: Tue, 19 May 2020 10:54:28 +0200 Subject: [PATCH] WIP: Add Nextcloud Talk --- apps/viktoriaapp.yaml | 2 + apps/viktoriamanagement.yaml | 2 + .../lib/src/aixformation_row.dart | 19 +- features/cafetoria/lib/src/cafetoria_row.dart | 9 +- .../calendar/lib/src/calendar_events.dart | 1 - features/calendar/lib/src/calendar_row.dart | 10 +- .../ipad_list/lib/src/ipad_list_group.dart | 10 +- .../ipad_list/lib/src/ipad_list_loader.dart | 12 +- features/ipad_list/lib/src/ipad_list_row.dart | 10 +- .../nextcloud_talk/lib/nextcloud_talk.dart | 79 +++++ .../lib/src/nextcloud_talk_chat_page.dart | 281 +++++++++++++++++ .../nextcloud_talk_create_chat_dialog.dart | 290 ++++++++++++++++++ .../lib/src/nextcloud_talk_events.dart | 4 + .../lib/src/nextcloud_talk_info_card.dart | 71 +++++ .../lib/src/nextcloud_talk_keys.dart | 7 + .../lib/src/nextcloud_talk_loader.dart | 93 ++++++ .../lib/src/nextcloud_talk_localizations.dart | 38 +++ .../src/nextcloud_talk_message_widget.dart | 189 ++++++++++++ .../lib/src/nextcloud_talk_model.dart | 195 ++++++++++++ .../lib/src/nextcloud_talk_page.dart | 115 +++++++ .../lib/src/nextcloud_talk_row.dart | 104 +++++++ .../lib/src/nextcloud_talk_utils.dart | 45 +++ features/nextcloud_talk/pubspec.yaml | 17 + .../lib/src/substitution_plan_row.dart | 75 +++-- .../lib/src/timetable_info_card.dart | 18 +- features/timetable/lib/src/timetable_row.dart | 137 +++++---- frame/lib/app_frame.dart | 14 +- frame/lib/main.dart | 1 + images/logo_management_green.svg | 16 +- images/logo_management_white.svg | 14 +- scripts/bin/create_app.dart | 2 + scripts/templates/pubspec.yaml.tmpl | 1 - utils/lib/src/loading/loader.dart | 91 +++--- utils/lib/src/loading/tags_loader.dart | 4 +- utils/lib/src/theme.dart | 14 +- .../lib/src/custom_cached_network_image.dart | 186 +++++------ widgets/lib/src/custom_row.dart | 75 ++--- widgets/lib/src/dialog_content_wrapper.dart | 6 +- widgets/lib/src/size_limit.dart | 6 +- widgets/pubspec.yaml | 1 - 40 files changed, 1905 insertions(+), 359 deletions(-) create mode 100644 features/nextcloud_talk/lib/nextcloud_talk.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_chat_page.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_create_chat_dialog.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_events.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_info_card.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_keys.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_loader.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_localizations.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_message_widget.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_model.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_page.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_row.dart create mode 100644 features/nextcloud_talk/lib/src/nextcloud_talk_utils.dart create mode 100644 features/nextcloud_talk/pubspec.yaml diff --git a/apps/viktoriaapp.yaml b/apps/viktoriaapp.yaml index 8aed85b..4f24d0b 100644 --- a/apps/viktoriaapp.yaml +++ b/apps/viktoriaapp.yaml @@ -7,6 +7,8 @@ features: full_name: Timetable - name: substitution_plan full_name: SubstitutionPlan + - name: nextcloud_talk + full_name: NextcloudTalk - name: calendar full_name: Calendar - name: cafetoria diff --git a/apps/viktoriamanagement.yaml b/apps/viktoriamanagement.yaml index 794180d..b6084df 100644 --- a/apps/viktoriamanagement.yaml +++ b/apps/viktoriamanagement.yaml @@ -7,6 +7,8 @@ features: full_name: Timetable - name: substitution_plan full_name: SubstitutionPlan + - name: nextcloud_talk + full_name: NextcloudTalk - name: ipad_list full_name: IPadList firebase_web: diff --git a/features/aixformation/lib/src/aixformation_row.dart b/features/aixformation/lib/src/aixformation_row.dart index 6667ea6..b20d29e 100644 --- a/features/aixformation/lib/src/aixformation_row.dart +++ b/features/aixformation/lib/src/aixformation_row.dart @@ -41,13 +41,20 @@ class AiXformationRow extends PreferredSize { }, child: CustomRow( leading: CustomCachedNetworkImage( - imageUrl: post.imageUrl, - height: 60, - width: 60, + provider: CustomCachedNetworkImageUrlProvider( + imageUrl: post.imageUrl, + ), + height: customRowHeight - 26, + width: customRowHeight - 26, + ), + title: Text( + post.title, + style: TextStyle( + fontSize: 17, + color: ThemeWidget.of(context).textColor, + ), + overflow: TextOverflow.ellipsis, ), - title: post.title, - titleColor: ThemeWidget.of(context).textColor, - titleFontWeight: FontWeight.normal, subtitle: IconsTexts( icons: [ Icons.event, diff --git a/features/cafetoria/lib/src/cafetoria_row.dart b/features/cafetoria/lib/src/cafetoria_row.dart index af15682..9016dc8 100644 --- a/features/cafetoria/lib/src/cafetoria_row.dart +++ b/features/cafetoria/lib/src/cafetoria_row.dart @@ -31,7 +31,14 @@ class CafetoriaRow extends PreferredSize { Icons.restaurant, color: ThemeWidget.of(context).textColorLight, ), - title: menu.name, + title: Text( + menu.name, + style: TextStyle( + fontSize: 17, + color: Theme.of(context).accentColor, + ), + overflow: TextOverflow.ellipsis, + ), subtitle: menu.price != 0 || menu.time.isNotEmpty ? IconsTexts( icons: [ diff --git a/features/calendar/lib/src/calendar_events.dart b/features/calendar/lib/src/calendar_events.dart index 2e5fc3f..6237aba 100644 --- a/features/calendar/lib/src/calendar_events.dart +++ b/features/calendar/lib/src/calendar_events.dart @@ -1,4 +1,3 @@ -// ignore: public_member_api_docs import 'package:utils/utils.dart'; // ignore: public_member_api_docs diff --git a/features/calendar/lib/src/calendar_row.dart b/features/calendar/lib/src/calendar_row.dart index ceed8e6..a075cd3 100644 --- a/features/calendar/lib/src/calendar_row.dart +++ b/features/calendar/lib/src/calendar_row.dart @@ -37,8 +37,14 @@ class CalendarRow extends PreferredSize { Icons.calendar_today, color: ThemeWidget.of(context).textColorLight, ), - title: '${event.name}', - titleOverflow: TextOverflow.ellipsis, + title: Text( + event.name, + style: TextStyle( + fontSize: 17, + color: Theme.of(context).accentColor, + ), + overflow: TextOverflow.ellipsis, + ), subtitle: Text( event.dateString, style: TextStyle( diff --git a/features/ipad_list/lib/src/ipad_list_group.dart b/features/ipad_list/lib/src/ipad_list_group.dart index 4de7091..f8318c5 100644 --- a/features/ipad_list/lib/src/ipad_list_group.dart +++ b/features/ipad_list/lib/src/ipad_list_group.dart @@ -108,8 +108,14 @@ class IPadGroupRow extends PreferredSize { ), ], ), - title: '${IPadListLocalizations.iPadGroup}: $groupName', - titleOverflow: TextOverflow.ellipsis, + title: Text( + '${IPadListLocalizations.iPadGroup}: $groupName', + style: TextStyle( + fontSize: 17, + color: Theme.of(context).accentColor, + ), + overflow: TextOverflow.ellipsis, + ), subtitle: Row( children: [ Text( diff --git a/features/ipad_list/lib/src/ipad_list_loader.dart b/features/ipad_list/lib/src/ipad_list_loader.dart index b53294d..0a1133f 100644 --- a/features/ipad_list/lib/src/ipad_list_loader.dart +++ b/features/ipad_list/lib/src/ipad_list_loader.dart @@ -61,7 +61,7 @@ class _HistoryLoader extends Loader { final status = super.loadOffline(context); // Save the parsed data in an extra attribute, because after each load online call, // the parsed data will be override by the new data - _history.entries = parsedData?.entries ?? {}; + _history.entries = data?.entries ?? {}; return status; } @@ -72,7 +72,7 @@ class _HistoryLoader extends Loader { // Add the loaded entries to the old entries if (oldData != null) { - parsedData.entries.forEach((key, value) { + data.entries.forEach((key, value) { _history.entries[key] = value; }); } @@ -81,8 +81,8 @@ class _HistoryLoader extends Loader { } /// Returns the battery history for the given devices - Future getDeviceHistory(BuildContext context, - List devices, DateTime date, + Future getDeviceHistory( + BuildContext context, List devices, DateTime date, {bool loadOffline = false}) async { final ids = devices.map((d) => d.id).toList(); @@ -100,8 +100,8 @@ class _HistoryLoader extends Loader { final history = DeviceHistory(entries: {}); for (final device in devices) { history.entries[device.id] = _history.entries[device.id] - ?.where((d) => !d.lastModified.isBefore(date)) - ?.toList() ?? + ?.where((d) => !d.lastModified.isBefore(date)) + ?.toList() ?? []; history.entries[device.id] .sort((d1, d2) => d1.lastModified.compareTo(d2.lastModified)); diff --git a/features/ipad_list/lib/src/ipad_list_row.dart b/features/ipad_list/lib/src/ipad_list_row.dart index be6572e..ee21c11 100644 --- a/features/ipad_list/lib/src/ipad_list_row.dart +++ b/features/ipad_list/lib/src/ipad_list_row.dart @@ -38,8 +38,14 @@ class IPadRow extends PreferredSize { color: ThemeWidget.of(context).textColorLight, ), ), - title: iPad.name, - titleOverflow: TextOverflow.ellipsis, + title: Text( + iPad.name, + style: TextStyle( + fontSize: 17, + color: Theme.of(context).accentColor, + ), + overflow: TextOverflow.ellipsis, + ), subtitle: BatteryIndicator( level: iPad.batteryLevel, isCharging: iPad.isCharging, diff --git a/features/nextcloud_talk/lib/nextcloud_talk.dart b/features/nextcloud_talk/lib/nextcloud_talk.dart new file mode 100644 index 0000000..af06628 --- /dev/null +++ b/features/nextcloud_talk/lib/nextcloud_talk.dart @@ -0,0 +1,79 @@ +library nextcloud_talk; + +import 'package:flutter/material.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_events.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_keys.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_loader.dart'; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import 'src/nextcloud_talk_info_card.dart'; +import 'src/nextcloud_talk_localizations.dart'; +import 'src/nextcloud_talk_page.dart'; + +/// The Nextcloud Talk feature +class NextcloudTalkFeature implements Feature { + @override + String get name => NextcloudTalkLocalizations.name; + + @override + String get featureKey => NextcloudTalkKeys.nextcloudTalk; + + @override + List dependsOn(BuildContext context) => null; + + @override + NextcloudTalkLoader loader = NextcloudTalkLoader(); + + @override + NotificationsHandler get notificationsHandler => null; + + @override + TagsHandler get tagsHandler => null; + + @override + InfoCard getInfoCard(DateTime date, double maxHeight) => + NextcloudTalkInfoCard( + date: date, + maxHeight: maxHeight, + ); + + @override + Widget getPage() => NextcloudTalkPage(key: ValueKey(featureKey)); + + @override + NextcloudTalkWidget getFeatureWidget(Widget child) => NextcloudTalkWidget( + feature: this, + key: ValueKey(featureKey), + child: child, + ); + + @override + DateTime getHomePageDate() => null; + + @override + Duration durationToHomePageDateUpdate() => null; + + @override + Subscription subscribeToDataUpdates( + EventBus eventBus, Function(ChangedEvent p1) callback) => + eventBus.respond(callback); +} + +// ignore: public_member_api_docs +class NextcloudTalkWidget extends FeatureWidget { + // ignore: public_member_api_docs + const NextcloudTalkWidget({ + @required Widget child, + @required NextcloudTalkFeature feature, + Key key, + }) : super( + child: child, + feature: feature, + key: key, + ); + + /// Find the closest [NextcloudTalkWidget] from ancestor tree. + static NextcloudTalkWidget of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_chat_page.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_chat_page.dart new file mode 100644 index 0000000..11d2022 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_chat_page.dart @@ -0,0 +1,281 @@ +import 'dart:async'; + +import 'package:after_layout/after_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/painting.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_keys.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_loader.dart'; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import '../nextcloud_talk.dart'; +import 'nextcloud_talk_localizations.dart'; +import 'nextcloud_talk_message_widget.dart'; +import 'nextcloud_talk_model.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkChatPage extends StatefulWidget { + // ignore: public_member_api_docs + const NextcloudTalkChatPage({ + @required this.chat, + Key key, + }) : super(key: key); + + // ignore: public_member_api_docs + final NextcloudTalkChat chat; + + @override + _NextcloudTalkChatPageState createState() => _NextcloudTalkChatPageState(); +} + +class _NextcloudTalkChatPageState extends State + with AfterLayoutMixin { + List _messages; + final ScrollController _scrollController = ScrollController(); + final _textController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _isComposing = false; + NextcloudTalkLoader _loader; + Timer _autoRefresh; + + @override + void initState() { + _messages = widget.chat.loadOfflineMessages(); + super.initState(); + } + + @override + void afterFirstLayout(BuildContext context) { + if (mounted) { + _loader = NextcloudTalkWidget.of(context).feature.loader; + _autoRefresh = Timer.periodic( + Duration( + seconds: 30, + ), (timer) { + _loadOnlineMessages(sendEvent: false); + }); + _loadOnlineMessages(); + } + } + + @override + void dispose() { + if (_autoRefresh != null) { + _autoRefresh.cancel(); + _autoRefresh = null; + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final loader = NextcloudTalkWidget.of(context).feature.loader; + loader.client.talk.messageManagement.getMessages(widget.chat.token); + return Scaffold( + appBar: CustomAppBar( + title: widget.chat.displayName, + loadingKeys: const [NextcloudTalkKeys.nextcloudTalk], + ), + body: (_messages == null || _messages.isEmpty) + ? Center( + child: EmptyList(title: NextcloudTalkLocalizations.noMessages), + ) + : Flex( + direction: Axis.vertical, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + padding: EdgeInsets.all(10), + itemCount: _messages.length, + shrinkWrap: true, + reverse: true, + itemBuilder: (context, index) => Center( + child: SizeLimit( + maxWidth: 900, + child: Row( + children: [ + if (_messages[_messages.length - index - 1] + .actorId == + Static.user.username) + Expanded( + flex: 10, + child: Container(), + ), + Expanded( + flex: 90, + child: Container( + margin: EdgeInsets.only( + top: index == _messages.length - 1 + ? 0 + : _messages[_messages.length - + index - + 1] + .actorId != + _messages[_messages.length - + index - + 2] + .actorId + ? 20 + : 5), + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + color: Color.lerp( + ThemeWidget.of(context).textColor, + Theme.of(context).backgroundColor, + 0.9, + ), + borderRadius: + BorderRadius.all(Radius.circular(5)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.chat.type != + ConversationType.oneToOne && + _messages[_messages.length - index - 1] + .actorId != + Static.user.username && + (index == _messages.length - 1 || + _messages[_messages.length - + index - + 1] + .actorId != + _messages[_messages.length - + index - + 2] + .actorId)) + Text( + _messages[_messages.length - index - 1] + .actorDisplayName, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).accentColor, + ), + ), + NextcloudTalkMessageWidget( + message: _messages[ + _messages.length - index - 1], + ), + ], + ), + ), + ), + if (_messages[_messages.length - index - 1] + .actorId != + Static.user.username) + Expanded( + flex: 10, + child: Container(), + ), + ], + ), + ), + ), + ), + ), + Divider(height: 1), + Container( + margin: EdgeInsets.only( + left: 20, + right: 4, + ), + child: Row( + children: [ + Flexible( + child: TextField( + controller: _textController, + onChanged: (text) { + setState(() { + _isComposing = text.isNotEmpty; + }); + }, + onSubmitted: _isComposing ? _handleSubmitted : null, + decoration: InputDecoration.collapsed( + hintText: NextcloudTalkLocalizations.writeMessage, + ), + focusNode: _focusNode, + minLines: 1, + maxLines: 3, + textInputAction: TextInputAction.newline, + ), + ), + Container( + margin: EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + icon: Icon( + Icons.send, + color: _isComposing + ? Theme.of(context).accentColor + : null, + ), + onPressed: _isComposing + ? () => _handleSubmitted(_textController.text) + : null, + ), + ) + ], + ), + ), + ], + ), + ); + } + + void _loadOnlineMessages({bool sendEvent = true}) { + final eventBus = EventBus.of(context); + final loadingState = LoadingState.of(context); + if (sendEvent) { + _loader.sendLoadingEvent(loadingState, eventBus); + } + widget.chat + .loadOnlineMessages(_loader.client.talk.messageManagement) + .then((messages) async { + if (mounted) { + setState(() => _messages = messages); + try { + await _loader.client.talk.messageManagement.markAsRead( + widget.chat.token, + widget.chat.lastMessage.id, + ); + await _loader.loadOnline( + context, + force: true, + ); + } on RequestException catch (e, stacktrace) { + print(e); + print(stacktrace); + } + } + if (sendEvent) { + _loader.sendLoadedEvent(loadingState, eventBus); + } + }); + } + + void _handleSubmitted(String text) { + _textController.clear(); + setState(() { + _isComposing = false; + }); + _focusNode.requestFocus(); + _scrollController.animateTo( + 0, + curve: Curves.easeOut, + duration: const Duration(milliseconds: 300), + ); + final loader = NextcloudTalkWidget.of(context).feature.loader + ..sendLoadingEvent(LoadingState.of(context), EventBus.of(context)); + loader.client.talk.messageManagement + .sendMessage(widget.chat.token, text) + .then((messages) { + if (mounted) { + _loadOnlineMessages(); + _loader.loadOnline(context, force: true); + } else { + _loader.sendLoadedEvent(LoadingState.of(context), EventBus.of(context)); + } + }); + } +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_create_chat_dialog.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_create_chat_dialog.dart new file mode 100644 index 0000000..8175f75 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_create_chat_dialog.dart @@ -0,0 +1,290 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart' as nextcloud; +import 'package:nextcloud_talk/nextcloud_talk.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_localizations.dart'; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import 'nextcloud_talk_utils.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkCreateChatDialog extends StatefulWidget { + @override + _NextcloudTalkCreateChatDialogState createState() => + _NextcloudTalkCreateChatDialogState(); +} + +class _NextcloudTalkCreateChatDialogState + extends State { + bool _groupChat = false; + final TextEditingController _searchFieldController = TextEditingController(); + List _selectedUsers = []; + List _suggestedUsers = []; + String _currentQuery = ''; + String _name; + bool _searchLoading = false; + + @override + Widget build(BuildContext context) => SimpleDialog( + contentPadding: EdgeInsets.only(left: 5, right: 5, top: 10), + title: Text( + NextcloudTalkLocalizations.createNewChat, + style: TextStyle( + color: ThemeWidget.of(context).textColor, + ), + ), + children: [ + DialogContentWrapper( + children: [ + Row( + children: [ + Expanded( + child: Text(NextcloudTalkLocalizations.groupChat), + ), + Switch( + value: _groupChat, + activeColor: Theme.of(context).accentColor, + onChanged: (value) { + setState(() { + _selectedUsers = [ + if (_selectedUsers.isNotEmpty) _selectedUsers[0], + ]; + _groupChat = value; + }); + }, + ), + ], + ), + if (_groupChat) + TextField( + decoration: InputDecoration( + hintText: NextcloudTalkLocalizations.chatName, + ), + onChanged: (name) { + setState(() { + _name = name; + }); + }, + ), + if (_selectedUsers.isEmpty) + Container( + margin: EdgeInsets.only(top: 7.5, bottom: 7.5), + child: Row( + children: [ + Icon( + Icons.error_outline, + color: Colors.red, + ), + Container( + margin: EdgeInsets.only(left: 5), + child: Text(_groupChat + ? NextcloudTalkLocalizations.noUsersSelected + : NextcloudTalkLocalizations.noUserSelected), + ), + ], + ), + ) + else + Builder( + builder: (context) { + final widgets = _usersToWidgets(_selectedUsers); + return Column( + children: List.generate( + widgets.length, + (index) => InkWell( + onTap: () { + setState(() { + _selectedUsers.removeAt(index); + }); + }, + child: Row( + children: [ + Expanded( + child: widgets[index], + ), + Container( + margin: EdgeInsets.only(right: 5), + child: Icon( + Icons.close, + size: 20, + ), + ), + ], + ), + ), + ), + ); + }, + ), + TextField( + decoration: InputDecoration( + hintText: NextcloudTalkLocalizations.searchUser, + ), + controller: _searchFieldController, + onChanged: (query) { + if (query == '') { + setState(() { + _currentQuery = ''; + _searchLoading = false; + _suggestedUsers = []; + }); + } else { + setState(() { + _searchLoading = true; + _currentQuery = query; + }); + NextcloudTalkWidget.of(context) + .feature + .loader + .client + .autocomplete + .searchUser(query) + .then((users) { + if (query == _currentQuery) { + if (mounted) { + setState(() { + _searchLoading = false; + _suggestedUsers = users + .where( + (user) => user.id != Static.user.username) + .toList(); + }); + } + } + }); + } + }, + ), + if (_currentQuery == '') + Container() + else if (_searchLoading) + Container( + margin: EdgeInsets.only(top: 5, bottom: 5), + child: Center( + child: SizedBox( + height: 30, + width: 30, + child: CustomCircularProgressIndicator(), + ), + ), + ) + else if (_suggestedUsers.isEmpty) + Container( + margin: EdgeInsets.only(top: 7.5, bottom: 7.5), + child: Row( + children: [ + Icon( + Icons.warning, + color: Colors.orangeAccent, + ), + Container( + margin: EdgeInsets.only(left: 5), + child: Text(NextcloudTalkLocalizations.noUsersFound), + ), + ], + ), + ) + else ...[ + Container( + margin: EdgeInsets.only(top: 5), + child: Text('${NextcloudTalkLocalizations.searchResults}:'), + ), + Builder( + builder: (context) { + final filteredUserSuggestions = _suggestedUsers + .where((u) => !_selectedUsers.contains(u)) + .toList(); + final widgets = _usersToWidgets(filteredUserSuggestions); + return Column( + children: List.generate( + widgets.length, + (index) => InkWell( + onTap: () { + setState(() { + if (!_groupChat) { + _selectedUsers = []; + } + _selectedUsers + .add(filteredUserSuggestions[index]); + }); + }, + child: Row( + children: [ + Expanded( + child: widgets[index], + ), + Container( + margin: EdgeInsets.only(right: 5), + child: Icon( + Icons.check, + size: 20, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + CustomButton( + onPressed: _selectedUsers.isNotEmpty && + (!_groupChat || (_name != null && _name.isNotEmpty)) + ? () { + Navigator.of(context).pop([ + _groupChat, + _name, + _selectedUsers.map((u) => u.id).toList(), + ]); + } + : null, + child: Text( + AppLocalizations.ok, + style: TextStyle(color: darkColor), + ), + ), + ], + ), + ], + ); + + List _usersToWidgets(List users) => users + .cast() + .map((user) => Container( + margin: EdgeInsets.only(top: 2.5, bottom: 2.5), + child: InkWell( + child: Container( + margin: EdgeInsets.only(top: 5, bottom: 5), + child: Row( + children: [ + SizedBox( + height: 30, + child: CustomCachedNetworkImage( + provider: CustomCachedNetworkImageAvatarProvider( + avatarClient: NextcloudTalkWidget.of(context) + .feature + .loader + .client + .avatar, + username: user.id, + size: 30, + ), + height: 30, + width: 30, + ), + ), + Container( + margin: EdgeInsets.only(left: 5), + child: Text(user.label), + ), + ], + ), + ), + ), + )) + .toList() + .cast() + .toList(); +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_events.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_events.dart new file mode 100644 index 0000000..b2ae58a --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_events.dart @@ -0,0 +1,4 @@ +import 'package:utils/utils.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkUpdateEvent extends ChangedEvent {} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_info_card.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_info_card.dart new file mode 100644 index 0000000..817a994 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_info_card.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud_talk/nextcloud_talk.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_localizations.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_model.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_page.dart'; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import 'nextcloud_talk_events.dart'; +import 'nextcloud_talk_keys.dart'; +import 'nextcloud_talk_row.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkInfoCard extends InfoCard { + // ignore: public_member_api_docs + const NextcloudTalkInfoCard({ + @required DateTime date, + double maxHeight, + }) : super( + date: date, + maxHeight: maxHeight, + ); + + @override + _NextcloudTalkInfoCardState createState() => _NextcloudTalkInfoCardState(); +} + +class _NextcloudTalkInfoCardState extends InfoCardState { + InfoCardUtils utils; + + @override + Subscription subscribeEvents(EventBus eventBus) => eventBus + .respond((event) => setState(() => null)); + + @override + ListGroup build(BuildContext context) { + final loader = NextcloudTalkWidget.of(context).feature.loader; + final _chats = loader.hasLoadedData + ? loader.data.chats.where((c) => c.unreadMessages > 0).toList() + : []; + final cut = InfoCardUtils.cut( + getScreenSize(MediaQuery.of(context).size.width), + _chats.length, + ); + return ListGroup( + loadingKeys: const [NextcloudTalkKeys.nextcloudTalk], + heroId: NextcloudTalkKeys.nextcloudTalk, + title: NextcloudTalkLocalizations.name, + counter: _chats.length - cut, + maxHeight: widget.maxHeight, + actions: [ + NavigationAction(Icons.expand_more, () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => NextcloudTalkPage(), + ), + ); + }), + ], + children: [ + if (_chats.isEmpty) + EmptyList(title: NextcloudTalkLocalizations.noUnreadMessages) + else + ...(_chats.length > cut ? _chats.sublist(0, cut) : _chats) + .map((chat) => NextcloudTalkRow(chat: chat)) + .toList() + .cast(), + ], + ); + } +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_keys.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_keys.dart new file mode 100644 index 0000000..28ad274 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_keys.dart @@ -0,0 +1,7 @@ +import 'package:utils/utils.dart'; + +/// All keys for the Nextcloud Talk feature +class NextcloudTalkKeys extends FeatureKeys { + // ignore: public_member_api_docs + static const nextcloudTalk = 'nextcloudTalk'; +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_loader.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_loader.dart new file mode 100644 index 0000000..4f3fb0b --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_loader.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:utils/utils.dart'; + +import 'nextcloud_talk_events.dart'; +import 'nextcloud_talk_keys.dart'; +import 'nextcloud_talk_model.dart'; + +/// NextcloudTalkLoader class +class NextcloudTalkLoader extends Loader { + // ignore: public_member_api_docs + NextcloudTalkLoader() + : super(NextcloudTalkKeys.nextcloudTalk, NextcloudTalkUpdateEvent()); + + @override + // ignore: type_annotate_public_apis, always_declare_return_types + fromJSON(json) => NextcloudTalk.fromJson(json); + + // ignore: public_member_api_docs + NextCloudClient get client => NextCloudClient( + 'nc.vs-ac.de', + Static.user.username, + Static.user.password, + ); + + @override + bool get forceUpdate => true; + + @override + Future> load( + BuildContext context, { + String username, + String password, + bool force = false, + bool post = false, + Map body, + bool store = true, + bool showLoginOnWrongCredentials = true, + }) async { + if (loadedFromOnline && !force) { + return LoaderResponse(statusCode: StatusCode.success); + } + + final loadingStates = context != null ? LoadingState.of(context) : null; + final eventBus = context != null ? EventBus.of(context) : null; + + // Inform the gui about this loading process + sendLoadingEvent(loadingStates, eventBus); + + try { + final conversations = + await client.talk.conversationManagement.getUserConversations(); + data = NextcloudTalk( + chats: conversations + .map((c) => NextcloudTalkChat( + name: c.name, + displayName: c.displayName, + type: c.type, + unreadMessages: c.unreadMessages, + lastActivity: c.lastActivity, + lastMessage: NextcloudTalkMessage.fromPackage(c.lastMessage), + token: c.token, + lastPing: c.lastPing, + )) + .toList() + .cast(), + ); + rawData = json.encode(data.toJson()); + save(); + loadedFromOnline = true; + sendLoadedEvent(loadingStates, eventBus); + return LoaderResponse( + data: data, + statusCode: StatusCode.success, + ); + } on RequestException catch (e, stacktrace) { + print(e); + print(stacktrace); + sendLoadedEvent(loadingStates, eventBus); + if (e.statusCode == 401) { + if (showLoginOnWrongCredentials && context != null) { + await Navigator.of(context).pushReplacementNamed('/${Keys.login}'); + } + } + return LoaderResponse( + statusCode: + e.statusCode == 401 ? StatusCode.unauthorized : StatusCode.failed, + ); + } + } +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_localizations.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_localizations.dart new file mode 100644 index 0000000..b93bbd5 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_localizations.dart @@ -0,0 +1,38 @@ +/// All localizations for the Nextcloud Talk feature +class NextcloudTalkLocalizations { + // ignore: public_member_api_docs + static const name = 'Talk'; + + // ignore: public_member_api_docs + static const noUnreadMessages = 'Keine ungelesenen Nachrichten'; + + // ignore: public_member_api_docs + static const createNewChat = 'Neuen Chat erstellen'; + + // ignore: public_member_api_docs + static const groupChat = 'Gruppen Chat'; + + // ignore: public_member_api_docs + static const searchUser = 'Nutzer*in suchen'; + + // ignore: public_member_api_docs + static const noUsersFound = 'Keine Nutzer*innen gefunden'; + + // ignore: public_member_api_docs + static const noUserSelected = 'Keine*n Nutzer*in ausgewählt'; + + // ignore: public_member_api_docs + static const noUsersSelected = 'Keine Nutzer*innen ausgewählt'; + + // ignore: public_member_api_docs + static const searchResults = 'Suchergebnisse'; + + // ignore: public_member_api_docs + static const chatName = 'Chat-Name'; + + // ignore: public_member_api_docs + static const noMessages = 'Keine Nachrichten'; + + // ignore: public_member_api_docs + static const writeMessage = 'Eine Nachricht schreiben...'; +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_message_widget.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_message_widget.dart new file mode 100644 index 0000000..ace04a1 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_message_widget.dart @@ -0,0 +1,189 @@ +// ignore: public_member_api_docs +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import '../nextcloud_talk.dart'; +import 'nextcloud_talk_model.dart'; +import 'nextcloud_talk_utils.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkMessageWidget extends StatelessWidget { + // ignore: public_member_api_docs + const NextcloudTalkMessageWidget({ + @required this.message, + this.overflow = TextOverflow.visible, + this.includeActorName = false, + this.singleLine = false, + }); + + // ignore: public_member_api_docs + final NextcloudTalkMessage message; + + // ignore: public_member_api_docs + final TextOverflow overflow; + + // ignore: public_member_api_docs + final bool includeActorName; + + // ignore: public_member_api_docs + final bool singleLine; + + @override + Widget build(BuildContext context) { + final List parts = []; + if (includeActorName && message.actorId != Static.user.username) { + var text = ''; + if (message.actorId.length <= 4) { + text += message.actorDisplayName.split(' ').sublist(1).join(' '); + } else { + text += message.actorDisplayName.split(' ')[0]; + } + text += ': '; + parts.add(TextSpan(text: text)); + } + final RegExp _urlRegex = RegExp( + r'^((?:.|\n)*?)((?:https?):\/\/[^\s/$.?#].[^\s]*)$', + caseSensitive: false, + ); + final List> data = + message.message.split('\n').map((t) => t.split(' ')).toList(); + for (final line in data) { + for (final part in line) { + bool added = false; + if (_urlRegex.hasMatch(part)) { + added = true; + parts.add(WidgetSpan( + child: Container( + margin: EdgeInsets.only(bottom: 2), + child: InkWell( + onTap: () => launch(part), + child: Text( + part, + style: TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + ), + ), + ), + )); + } else { + for (final key in message.messageParameters.keys.toList()) { + if (part == '{$key}') { + added = true; + final data = message.messageParameters[key]; + final type = data['type']; + switch (type) { + case 'file': + parts.add(WidgetSpan( + child: Container( + margin: EdgeInsets.only(bottom: 2), + child: InkWell( + onTap: () => launch(data['link']), + child: Text( + data['name'], + style: TextStyle( + color: Colors.blueAccent, + decoration: TextDecoration.underline, + ), + ), + ), + ), + )); + break; + case 'user': + parts.add(WidgetSpan( + child: Container( + decoration: BoxDecoration( + color: Color.lerp( + ThemeWidget.of(context).textColor, + Theme.of(context).backgroundColor, + 0.75, + ), + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: Stack( + children: [ + Container( + margin: EdgeInsets.only( + left: 2, + top: 2, + bottom: 2, + ), + height: 16, + width: 16, + child: CustomCachedNetworkImage( + provider: CustomCachedNetworkImageAvatarProvider( + avatarClient: NextcloudTalkWidget.of(context) + .feature + .loader + .client + .avatar, + username: data['id'], + size: 16, + ), + ), + ), + Container( + margin: EdgeInsets.only( + top: 2, + bottom: 2, + left: 20, + right: 4, + ), + child: Text( + data['name'], + ), + ), + ], + ), + ), + )); + break; + default: + print('Type \'$type\' not implemented'); + added = false; + } + } + } + } + if (!added) { + if (singleLine) { + parts.add(TextSpan(text: part.replaceAll('\n', ''))); + } else { + if (data.indexOf(line) != 0 && line.indexOf(part) == 0) { + // Inserts a newline in front of the next line to prevent + // a space that would appear in front of the line + parts.add(TextSpan(text: '\n$part')); + } else { + parts.add(TextSpan(text: part)); + } + } + } + } + } + + return Text.rich( + TextSpan( + children: parts + .map((p) => [ + p, + if (parts.indexOf(p) != parts.length - 1) + TextSpan( + text: ' ', + ), + ]) + .toList() + .expand((x) => x) + .toList(), + ), + style: TextStyle( + height: 1.2, + ), + textAlign: TextAlign.start, + overflow: overflow, + ); + } +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_model.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_model.dart new file mode 100644 index 0000000..6739cbd --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_model.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_keys.dart'; +import 'package:utils/utils.dart'; + +// ignore: public_member_api_docs +class NextcloudTalk { + // ignore: public_member_api_docs + NextcloudTalk({ + @required this.chats, + }) { + chats.sort((a, b) { + if ((a.unreadMessages > 0 && b.unreadMessages > 0) || + (a.unreadMessages == 0 && b.unreadMessages == 0)) { + return b.lastActivity.millisecondsSinceEpoch + .compareTo(a.lastActivity.millisecondsSinceEpoch); + } else { + return a.unreadMessages > 0 ? -1 : 1; + } + }); + } + + // ignore: public_member_api_docs + factory NextcloudTalk.fromJson(Map json) => NextcloudTalk( + chats: json['chats'] + .map((e) => NextcloudTalkChat.fromJson(e)) + .toList() + .cast(), + ); + + // ignore: public_member_api_docs + Map toJson() => { + 'chats': chats.map((c) => c.toJson()).toList(), + }; + + // ignore: public_member_api_docs + final List chats; +} + +// ignore: public_member_api_docs +class NextcloudTalkChat { + // ignore: public_member_api_docs + NextcloudTalkChat({ + @required this.name, + @required this.displayName, + @required this.type, + @required this.unreadMessages, + @required this.lastActivity, + @required this.lastPing, + @required this.lastMessage, + @required this.token, + }); + + // ignore: public_member_api_docs + factory NextcloudTalkChat.fromJson(Map json) => + NextcloudTalkChat( + name: json['name'], + displayName: json['displayName'], + type: ConversationType.values[json['type']], + unreadMessages: json['unreadMessages'], + lastActivity: DateTime.parse(json['lastActivity']).toLocal(), + lastPing: DateTime.parse(json['lastPing']).toLocal(), + lastMessage: NextcloudTalkMessage.fromJson(json['lastMessage']), + token: json['token'], + ); + + // ignore: public_member_api_docs + Map toJson() => { + 'name': name, + 'displayName': displayName, + 'type': type.index, + 'unreadMessages': unreadMessages, + 'lastActivity': lastActivity.toIso8601String(), + 'lastPing': lastPing.toIso8601String(), + 'lastMessage': lastMessage.toJson(), + 'token': token, + }; + + /// Load the message from cache + List loadOfflineMessages() { + final data = + Static.storage.getJSON('${NextcloudTalkKeys.nextcloudTalk}-$token'); + if (data == null) { + return null; + } + return data + .cast>() + .map((m) => NextcloudTalkMessage.fromJson(m)) + .toList() + .cast() + .toList(); + } + + /// Load the messages from the server + Future> loadOnlineMessages( + MessageManagement messageManagement, + ) async { + final messages = (await messageManagement.getMessages(token)) + .map((m) => NextcloudTalkMessage.fromPackage(m)) + .toList() + .reversed + .toList(); + Static.storage.setJSON('${NextcloudTalkKeys.nextcloudTalk}-$token', + messages.map((m) => m.toJson()).toList()); + return messages; + } + + // ignore: public_member_api_docs + final String name; + + // ignore: public_member_api_docs + final String displayName; + + // ignore: public_member_api_docs + final ConversationType type; + + // ignore: public_member_api_docs + final int unreadMessages; + + // ignore: public_member_api_docs + final DateTime lastActivity; + + // ignore: public_member_api_docs + final DateTime lastPing; + + // ignore: public_member_api_docs + final NextcloudTalkMessage lastMessage; + + // ignore: public_member_api_docs + final String token; +} + +// ignore: public_member_api_docs +class NextcloudTalkMessage { + // ignore: public_member_api_docs + NextcloudTalkMessage({ + @required this.message, + @required this.actorDisplayName, + @required this.actorId, + @required this.id, + @required this.timestamp, + @required this.messageParameters, + }); + + // ignore: public_member_api_docs + factory NextcloudTalkMessage.fromJson(Map json) => + NextcloudTalkMessage( + message: json['message'], + actorDisplayName: json['actorDisplayName'], + actorId: json['actorId'], + id: json['id'], + timestamp: DateTime.parse(json['timestamp']).toLocal(), + messageParameters: json['messageParameters'], + ); + + // ignore: public_member_api_docs + factory NextcloudTalkMessage.fromPackage(Message message) => + NextcloudTalkMessage( + message: message.message, + actorDisplayName: message.actorDisplayName, + actorId: message.actorId, + id: message.id, + timestamp: message.timestamp, + messageParameters: + message.messageParameters is Map ? message.messageParameters : {}, + ); + + // ignore: public_member_api_docs + Map toJson() => { + 'message': message, + 'actorDisplayName': actorDisplayName, + 'actorId': actorId, + 'id': id, + 'timestamp': timestamp.toIso8601String(), + 'messageParameters': messageParameters, + }; + + // ignore: public_member_api_docs + final String message; + + // ignore: public_member_api_docs + final String actorDisplayName; + + // ignore: public_member_api_docs + final String actorId; + + // ignore: public_member_api_docs + final int id; + + // ignore: public_member_api_docs + final DateTime timestamp; + + // ignore: public_member_api_docs + final Map messageParameters; +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_page.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_page.dart new file mode 100644 index 0000000..ccd37d1 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_page.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_event_bus/flutter_event_bus.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud_talk/nextcloud_talk.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_create_chat_dialog.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_row.dart'; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import 'nextcloud_talk_chat_page.dart'; +import 'nextcloud_talk_events.dart'; +import 'nextcloud_talk_keys.dart'; +import 'nextcloud_talk_localizations.dart'; +import 'nextcloud_talk_model.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkPage extends StatefulWidget { + // ignore: public_member_api_docs + const NextcloudTalkPage({Key key}) : super(key: key); + + @override + _NextcloudTalkPageState createState() => _NextcloudTalkPageState(); +} + +class _NextcloudTalkPageState extends Interactor + with TickerProviderStateMixin { + List chats; + + @override + Subscription subscribeEvents(EventBus eventBus) => eventBus + .respond((event) => setState(() => null)); + + @override + Widget build(BuildContext context) { + final loader = NextcloudTalkWidget.of(context).feature.loader; + final _chats = loader.hasLoadedData + ? loader.data.chats.toList() + : []; + return Scaffold( + appBar: CustomAppBar( + title: NextcloudTalkLocalizations.name, + loadingKeys: const [NextcloudTalkKeys.nextcloudTalk], + actions: [ + IconButton( + icon: Icon(Icons.group_add), + onPressed: () async { + final args = await showDialog( + context: context, + builder: (context) => NextcloudTalkCreateChatDialog(), + ); + if (args != null && args.isNotEmpty) { + final bool groupChat = args[0]; + final String name = args[1]; + final List users = args[2]; + final talk = + NextcloudTalkWidget.of(context).feature.loader.client.talk; + NextcloudTalkWidget.of(context) + .feature + .loader + .sendLoadingEvent(LoadingState.of(context), eventBus); + if (groupChat) { + final token = + await talk.conversationManagement.createConversation( + ConversationType.group, + name: name, + ); + for (final user in users) { + await talk.conversationManagement + .addParticipant(token, user); + } + } else { + await talk.conversationManagement.createConversation( + ConversationType.oneToOne, + invite: users.first, + ); + } + await NextcloudTalkWidget.of(context) + .feature + .loader + .loadOnline(context, force: true); + } + }, + ) + ], + ), + body: loader.hasLoadedData + ? CustomRefreshIndicator( + loadOnline: () => loader.loadOnline(context, force: true), + child: Scrollbar( + child: ListView.builder( + padding: EdgeInsets.all(10), + itemCount: _chats.length, + itemBuilder: (context, index) => SizeLimit( + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => NextcloudTalkChatPage( + chat: _chats[index], + ), + ), + ); + }, + child: NextcloudTalkRow( + chat: _chats[index], + ), + ), + ), + ), + ), + ) + : EmptyList(title: NextcloudTalkLocalizations.noUnreadMessages), + ); + } +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_row.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_row.dart new file mode 100644 index 0000000..d584672 --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_row.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/painting.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:nextcloud_talk/nextcloud_talk.dart'; +import 'package:nextcloud_talk/src/nextcloud_talk_utils.dart'; +import 'package:timeago/timeago.dart' as timeago; +import 'package:utils/utils.dart'; +import 'package:widgets/widgets.dart'; + +import 'nextcloud_talk_message_widget.dart'; +import 'nextcloud_talk_model.dart'; + +// ignore: public_member_api_docs +class NextcloudTalkRow extends PreferredSize { + // ignore: public_member_api_docs + const NextcloudTalkRow({ + @required this.chat, + }); + + // ignore: public_member_api_docs + final NextcloudTalkChat chat; + + @override + Size get preferredSize => Size.fromHeight(customRowHeight); + + @override + Widget build(BuildContext context) => CustomRow( + leading: Stack( + children: [ + if (chat.type == ConversationType.oneToOne) + SizedBox( + height: customRowHeight - 30, + width: customRowHeight - 30, + child: CustomCachedNetworkImage( + provider: CustomCachedNetworkImageAvatarProvider( + avatarClient: NextcloudTalkWidget.of(context) + .feature + .loader + .client + .avatar, + username: chat.name, + size: customRowHeight.toInt() - 30, + ), + height: customRowHeight - 30, + width: customRowHeight - 30, + ), + ) + else + Icon( + Icons.group, + color: ThemeWidget.of(context).textColorLight, + ), + if (chat.unreadMessages > 0) + Align( + alignment: Alignment.topRight, + child: Container( + height: 6.5, + width: 6.5, + margin: EdgeInsets.only( + top: 1.5, + right: 1.5, + ), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).accentColor, + boxShadow: [ + BoxShadow( + color: Colors.black, + spreadRadius: 0.1, + blurRadius: 0.1, + ), + ], + ), + ), + ), + ], + ), + title: Text( + chat.displayName, + style: TextStyle( + fontSize: 17, + color: chat.unreadMessages > 0 + ? Theme.of(context).accentColor + : Colors.grey, + ), + overflow: TextOverflow.ellipsis, + ), + subtitle: NextcloudTalkMessageWidget( + message: chat.lastMessage, + includeActorName: true, + singleLine: true, + overflow: TextOverflow.ellipsis, + ), + last: Text( + timeago.format( + chat.lastActivity, + locale: 'de_short', + ), + style: TextStyle( + fontWeight: FontWeight.w100, + ), + ), + ); +} diff --git a/features/nextcloud_talk/lib/src/nextcloud_talk_utils.dart b/features/nextcloud_talk/lib/src/nextcloud_talk_utils.dart new file mode 100644 index 0000000..c61e53d --- /dev/null +++ b/features/nextcloud_talk/lib/src/nextcloud_talk_utils.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:widgets/widgets.dart'; + +// ignore: public_member_api_docs +class CustomCachedNetworkImageAvatarProvider + implements CustomCachedNetworkImageProvider { + // ignore: public_member_api_docs + CustomCachedNetworkImageAvatarProvider({ + @required this.username, + @required this.size, + @required this.avatarClient, + }); + + // ignore: public_member_api_docs + final String username; + + // ignore: public_member_api_docs + final int size; + + // ignore: public_member_api_docs + final AvatarClient avatarClient; + + @override + String get identifier => 'talk-$username-$size'; + + @override + Future loadImage() async => + base64.decode(await avatarClient.getAvatar(username, size)); + + @override + Widget imageWrapper(BuildContext context, Uint8List image) => Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + image: DecorationImage( + fit: BoxFit.fill, + image: MemoryImage(image), + ), + ), + ); +} diff --git a/features/nextcloud_talk/pubspec.yaml b/features/nextcloud_talk/pubspec.yaml new file mode 100644 index 0000000..3754204 --- /dev/null +++ b/features/nextcloud_talk/pubspec.yaml @@ -0,0 +1,17 @@ +name: nextcloud_talk + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + nextcloud: + dio: + url_launcher: + after_layout: + timeago: + utils: + path: ../../utils + widgets: + path: ../../widgets \ No newline at end of file diff --git a/features/substitution_plan/lib/src/substitution_plan_row.dart b/features/substitution_plan/lib/src/substitution_plan_row.dart index 5b4501d..812c7fa 100644 --- a/features/substitution_plan/lib/src/substitution_plan_row.dart +++ b/features/substitution_plan/lib/src/substitution_plan_row.dart @@ -93,29 +93,32 @@ class SubstitutionPlanRow extends PreferredSize { : (substitution.type == 2 ? Colors.red : Colors.orange), leading: showUnit ? Text( - (substitution.unit + 1).toString(), + (substitution.unit + 1).toString(), + style: TextStyle( + fontSize: 25, + color: ThemeWidget.of(context).textColorLight, + fontWeight: FontWeight.w100, + ), + ) + : keepUnitPadding ? Container() : null, + title: Text( + Static.subjects.hasLoadedData + ? (infoText.isNotEmpty + ? infoText.join(' ') + : Static.subjects.data + .getSubject(substitution.original.subjectID)) + : '', style: TextStyle( - fontSize: 25, - color: ThemeWidget - .of(context) - .textColorLight, - fontWeight: FontWeight.w100, + fontSize: 17, + color: Theme.of(context).accentColor, ), - ) - : keepUnitPadding ? Container() : null, - title: Static.subjects.hasLoadedData - ? (infoText.isNotEmpty - ? infoText.join(' ') - : Static.subjects.data - .getSubject(substitution.original.subjectID)) - : null, + overflow: TextOverflow.ellipsis, + ), subtitle: Text( subtitle, style: TextStyle( decoration: lineThrough ? TextDecoration.lineThrough : null, - color: ThemeWidget - .of(context) - .textColorLight, + color: ThemeWidget.of(context).textColorLight, fontWeight: FontWeight.w100, ), ), @@ -131,30 +134,26 @@ class SubstitutionPlanRow extends PreferredSize { substitution.changed.participantID != null ? _getWithCase(substitution.changed.participantID) : substitution.original.participantID != null - ? _getWithCase(substitution.original.participantID) - : '', + ? _getWithCase(substitution.original.participantID) + : '', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w300, - color: ThemeWidget - .of(context) - .textColor, + color: ThemeWidget.of(context).textColor, fontFamily: 'RobotoMono', ), ), Text( substitution.original.participantID != null && - substitution.changed.participantID != null && - substitution.original.participantID != - substitution.changed.participantID + substitution.changed.participantID != null && + substitution.original.participantID != + substitution.changed.participantID ? substitution.original.participantID.toUpperCase() : '', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w300, - color: ThemeWidget - .of(context) - .textColor, + color: ThemeWidget.of(context).textColor, decoration: TextDecoration.lineThrough, fontFamily: 'RobotoMono', ), @@ -171,30 +170,26 @@ class SubstitutionPlanRow extends PreferredSize { substitution.changed.roomID != null ? substitution.changed.roomID.toUpperCase() : substitution.original.roomID != null - ? substitution.original.roomID.toUpperCase() - : '', + ? substitution.original.roomID.toUpperCase() + : '', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w300, - color: ThemeWidget - .of(context) - .textColor, + color: ThemeWidget.of(context).textColor, fontFamily: 'RobotoMono', ), ), Text( substitution.type != 1 && - substitution.original.roomID != null && - substitution.changed.roomID != null && - substitution.original.roomID != - substitution.changed.roomID + substitution.original.roomID != null && + substitution.changed.roomID != null && + substitution.original.roomID != + substitution.changed.roomID ? substitution.original.roomID.toUpperCase() : '', style: TextStyle( fontSize: 14, - color: ThemeWidget - .of(context) - .textColor, + color: ThemeWidget.of(context).textColor, decoration: TextDecoration.lineThrough, fontWeight: FontWeight.w300, fontFamily: 'RobotoMono', diff --git a/features/timetable/lib/src/timetable_info_card.dart b/features/timetable/lib/src/timetable_info_card.dart index 2f94135..2a50643 100644 --- a/features/timetable/lib/src/timetable_info_card.dart +++ b/features/timetable/lib/src/timetable_info_card.dart @@ -48,18 +48,18 @@ class _TimetableInfoCardState extends InfoCardState { subjects.length, ); return ListGroup( - loadingKeys: [TimetableKeys.timetable], + loadingKeys: const [TimetableKeys.timetable], title: 'Nächste Stunden - ${weekdays[widget.date.weekday - 1]}', counter: subjects.length > cut ? subjects.length - cut : 0, heroId: - getScreenSize(MediaQuery.of(context).size.width) == ScreenSize.small - ? TimetableKeys.timetable - : '${TimetableKeys.timetable}-${widget.date.weekday - 1}', + getScreenSize(MediaQuery.of(context).size.width) == ScreenSize.small + ? TimetableKeys.timetable + : '${TimetableKeys.timetable}-${widget.date.weekday - 1}', heroIdNavigation: TimetableKeys.timetable, actions: [ NavigationAction( Icons.expand_more, - () { + () { Navigator.of(context).push( MaterialPageRoute(builder: (context) => TimetablePage()), ); @@ -74,14 +74,14 @@ class _TimetableInfoCardState extends InfoCardState { EmptyList( title: loader.data?.selection?.isSet(group) ?? true ? loader.hasLoadedData && subjects.isEmpty - ? TimetableLocalizations.noSubjects - : TimetableLocalizations.noTimetable + ? TimetableLocalizations.noSubjects + : TimetableLocalizations.noTimetable : TimetableLocalizations.notSelected) else ...(subjects.length > cut ? subjects.sublist(0, cut) : subjects) .map((subject) { - final substitutions = spLoader.hasLoadedData - ? subject.getSubstitutions(widget.date, spLoader.data) + final substitutions = spLoader.hasLoadedData + ? subject.getSubstitutions(widget.date, spLoader.data) : []; // Show the normal lessen if it is an exam, but not of the same subjects, as this unit final showNormal = substitutions.length == 1 && diff --git a/features/timetable/lib/src/timetable_row.dart b/features/timetable/lib/src/timetable_row.dart index 2a30520..69ad142 100644 --- a/features/timetable/lib/src/timetable_row.dart +++ b/features/timetable/lib/src/timetable_row.dart @@ -73,17 +73,22 @@ class TimetableRow extends PreferredSize { ), ) : null, - titleAlignment: - showCenterInfo ? CrossAxisAlignment.center : CrossAxisAlignment.start, - title: Static.subjects.hasLoadedData && subject.subjectID != 'none' - ? Static.subjects.data.getSubject(subject.subjectID) - : TimetableLocalizations.notSelected, - titleFontWeight: showCenterInfo ? FontWeight.w100 : null, - titleColor: showCenterInfo - ? theme.textColor - : useOpacity - ? Theme.of(context).accentColor.withOpacity(opacity) - : Theme.of(context).accentColor, + title: Text( + Static.subjects.hasLoadedData && subject.subjectID != 'none' + ? Static.subjects.data.getSubject(subject.subjectID) + : TimetableLocalizations.notSelected, + style: TextStyle( + fontSize: 17, + color: showCenterInfo + ? theme.textColor + : useOpacity + ? Theme.of(context).accentColor.withOpacity(opacity) + : Theme.of(context).accentColor, + fontWeight: showCenterInfo ? FontWeight.w100 : null, + ), + textAlign: showCenterInfo ? TextAlign.center : TextAlign.start, + overflow: TextOverflow.ellipsis, + ), subtitle: !showCenterInfo ? Text( subject.subjectID != 'none' @@ -99,62 +104,64 @@ class TimetableRow extends PreferredSize { : null, last: subject.subjectID != 'none' ? !showCenterInfo - ? Row( - children: [ - Container( - width: 35, - margin: EdgeInsets.only(right: 10), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (participants.isNotEmpty) - Text( - _getWithCase(participants[0]), - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - color: theme.textColor, - fontFamily: 'RobotoMono', + ? Row( + children: [ + Container( + width: 35, + margin: EdgeInsets.only(right: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (participants.isNotEmpty) + Text( + _getWithCase(participants[0]), + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + color: theme.textColor, + fontFamily: 'RobotoMono', + ), + ), + Text( + participants.length > 1 + ? _getWithCase(participants[1]) + : '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + color: theme.textColor, + decoration: TextDecoration.lineThrough, + fontFamily: 'RobotoMono', + ), + ), + ], + ), ), - ), - Text( - participants.length > 1 ? _getWithCase(participants[1]) : '', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - color: theme.textColor, - decoration: TextDecoration.lineThrough, - fontFamily: 'RobotoMono', - ), - ), - ], - ), - ), - Container( - width: 30, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - subject.roomID, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w300, - color: theme.textColor, - fontFamily: 'RobotoMono', - ), - ), - Text(''), - ], - ), - ), - ], - ) - : Container() + Container( + width: 30, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + subject.roomID, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + color: theme.textColor, + fontFamily: 'RobotoMono', + ), + ), + Text(''), + ], + ), + ), + ], + ) + : Container() : Icon( - MdiIcons.exclamation, - color: theme.textColorLight, - ), + MdiIcons.exclamation, + color: theme.textColorLight, + ), ); } } diff --git a/frame/lib/app_frame.dart b/frame/lib/app_frame.dart index c6f4a8a..f28b33a 100644 --- a/frame/lib/app_frame.dart +++ b/frame/lib/app_frame.dart @@ -40,8 +40,10 @@ class _AppFrameState extends Interactor }); }); - Future _fetchDataWithStatusMsg( - {bool force = false, ScaffoldState scaffoldState}) async { + Future _fetchDataWithStatusMsg({ + bool force = false, + ScaffoldState scaffoldState, + }) async { final scaffold = scaffoldState ?? Scaffold.of(context); final status = await _fetchData(force: force); if (status != StatusCode.success) { @@ -57,9 +59,7 @@ class _AppFrameState extends Interactor } } - Future _fetchData({ - bool force = false, - }) async { + Future _fetchData({bool force = false}) async { // Check all updates (If there is something new to update) final response = await Static.updates.fetch(context, showLoginOnWrongCredentials: false); @@ -166,9 +166,7 @@ class _AppFrameState extends Interactor Future afterFirstLayout(BuildContext context) async { final scaffold = Scaffold.of(context); Static.updates.loadOffline(context); - if (Static.updates.data == null) { - Static.updates.parsedData = Updates.fromJson({}); - } + Static.updates.data ??= Updates.fromJson({}); Static.subjects.loadOffline(context); await _loadData(online: false); diff --git a/frame/lib/main.dart b/frame/lib/main.dart index 272baf2..646e072 100644 --- a/frame/lib/main.dart +++ b/frame/lib/main.dart @@ -19,6 +19,7 @@ Future startApp({ WidgetsFlutterBinding.ensureInitialized(); timeago.setLocaleMessages('de', timeago.DeMessages()); + timeago.setLocaleMessages('de_short', timeago.DeShortMessages()); Static.storage = Storage(); await Static.storage.init(); diff --git a/images/logo_management_green.svg b/images/logo_management_green.svg index 64f030b..b73cf5a 100644 --- a/images/logo_management_green.svg +++ b/images/logo_management_green.svg @@ -1,11 +1,9 @@ - - - - - - - - - + + + + + + + \ No newline at end of file diff --git a/images/logo_management_white.svg b/images/logo_management_white.svg index c7cc56b..2df2466 100644 --- a/images/logo_management_white.svg +++ b/images/logo_management_white.svg @@ -1,10 +1,8 @@ - - - - - - - - + + + + + + \ No newline at end of file diff --git a/scripts/bin/create_app.dart b/scripts/bin/create_app.dart index 7427dc6..1666963 100644 --- a/scripts/bin/create_app.dart +++ b/scripts/bin/create_app.dart @@ -389,6 +389,7 @@ Future main(List arguments) async { 'flutter', [ 'pub', + 'global', 'run', 'flutter_launcher_icons:main', '-f', @@ -400,6 +401,7 @@ Future main(List arguments) async { 'flutter', [ 'pub', + 'global', 'run', 'flutter_launcher_icons:main', '-f', diff --git a/scripts/templates/pubspec.yaml.tmpl b/scripts/templates/pubspec.yaml.tmpl index 849c7c4..7bca1d1 100644 --- a/scripts/templates/pubspec.yaml.tmpl +++ b/scripts/templates/pubspec.yaml.tmpl @@ -19,7 +19,6 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_launcher_icons: flutter: uses-material-design: true diff --git a/utils/lib/src/loading/loader.dart b/utils/lib/src/loading/loader.dart index 848d128..57db098 100644 --- a/utils/lib/src/loading/loader.dart +++ b/utils/lib/src/loading/loader.dart @@ -29,41 +29,47 @@ abstract class Loader { dynamic get postBody => null; // ignore: public_member_api_docs - LoaderType parsedData; + LoaderType data; // ignore: public_member_api_docs, type_annotate_public_apis, always_declare_return_types LoaderType fromJSON(json); - // ignore: public_member_api_docs - LoaderType get data => parsedData; - /// If the loader must be updated bool get forceUpdate => false; /// The base url for all request BaseUrl get baseUrl => BaseUrl.viktoriaApp; - /// The raw downloaded json string - String _rawData; + // ignore: public_member_api_docs + String rawData; - bool _loadedFromOnline = false; + // ignore: public_member_api_docs + bool loadedFromOnline = false; - LoaderResponse _fromJSON(String rawJson) { + // ignore: public_member_api_docs + LoaderResponse loadFromJSON(String rawJson) { try { final data = fromJSON(json.decode(rawJson)); return LoaderResponse( - data: data, statusCode: StatusCode.success); + data: data, + statusCode: StatusCode.success, + ); // ignore: avoid_catches_without_on_clauses } catch (e, stacktrace) { print('Failed to parse $key: $e\n$stacktrace'); return LoaderResponse( - data: null, statusCode: StatusCode.wrongFormat); + data: null, + statusCode: StatusCode.wrongFormat, + ); } } /// Update the loader if the update hash has changed - Future update(BuildContext context, Updates newUpdates, - {bool force = false}) async { + Future update( + BuildContext context, + Updates newUpdates, { + bool force = false, + }) async { final hash = newUpdates.getUpdate(key); if (force || forceUpdate || @@ -82,14 +88,14 @@ abstract class Loader { StatusCode loadOffline(BuildContext context) { if (hasStoredData) { preLoad(context); - _rawData = Static.storage.getString(key); - final parsed = _fromJSON(_rawData); - parsedData = parsed.data; + rawData = Static.storage.getString(key); + final parsed = loadFromJSON(rawData); + data = parsed.data; if (parsed.statusCode != StatusCode.success) { Static.storage.remove(key); } afterLoad(); - _sendLoadedEvent(LoadingState.of(context), EventBus.of(context)); + sendLoadedEvent(LoadingState.of(context), EventBus.of(context)); return parsed.statusCode; } return StatusCode.success; @@ -106,7 +112,7 @@ abstract class Loader { bool store = true, bool showLoginOnWrongCredentials = true, }) async => - (await _load( + (await load( context, username: username, password: password, @@ -127,7 +133,7 @@ abstract class Loader { Map body, bool showLoginOnWrongCredentials = true, }) => - _load( + load( context, username: username, password: password, @@ -147,7 +153,7 @@ abstract class Loader { void afterLoad() => {}; /// Download the data from the api and returns the status code - Future> _load( + Future> load( BuildContext context, { String username, String password, @@ -157,7 +163,7 @@ abstract class Loader { bool store = true, bool showLoginOnWrongCredentials = true, }) async { - if (_loadedFromOnline && !force) { + if (loadedFromOnline && !force) { return LoaderResponse(statusCode: StatusCode.success); } @@ -167,7 +173,7 @@ abstract class Loader { final eventBus = context != null ? EventBus.of(context) : null; // Inform the gui about this loading process - _sendLoadingEvent(loadingStates, eventBus); + sendLoadingEvent(loadingStates, eventBus); // Run the pre load for custom loader operations preLoad(context); @@ -204,29 +210,28 @@ abstract class Loader { } final successfully = response.statusCode == 200; final statusCodes = [getStatusCode(response.statusCode)]; + LoaderType _data; if (store) { if (successfully) { - _rawData = response.toString(); - final parsed = _fromJSON(_rawData); - parsedData = parsed.data ?? parsedData; + rawData = response.toString(); + final parsed = loadFromJSON(rawData); + data ??= parsed.data; statusCodes.add(parsed.statusCode); if (parsed.statusCode == StatusCode.success) { save(); } - _loadedFromOnline = true; + loadedFromOnline = true; } else { print('$key failed to load'); } + } else { + _data = loadFromJSON(response.toString())?.data; } if (response.statusCode == 401 && showLoginOnWrongCredentials && context != null) { await Navigator.of(context).pushReplacementNamed('/${Keys.login}'); } - LoaderType data; - if (!store) { - data = _fromJSON(response.toString())?.data; - } try { afterLoad(); @@ -236,8 +241,7 @@ abstract class Loader { statusCodes.add(StatusCode.wrongFormat); } - afterLoad(); - _sendLoadedEvent(loadingStates, eventBus); + sendLoadedEvent(loadingStates, eventBus); final status = reduceStatusCodes(statusCodes); if (status != StatusCode.success) { print( @@ -245,10 +249,13 @@ abstract class Loader { } print( 'Loaded $key: ${DateTime.now().difference(start).inMilliseconds}ms'); - return LoaderResponse(data: data, statusCode: status); + return LoaderResponse( + data: _data ?? data, + statusCode: status, + ); } on DioError catch (e) { afterLoad(); - _sendLoadedEvent(loadingStates, eventBus); + sendLoadedEvent(loadingStates, eventBus); print( 'Failed loading $key: ${DateTime.now().difference(start).inMilliseconds}ms'); switch (e.type) { @@ -278,33 +285,33 @@ abstract class Loader { } /// Sets the page loading state - void _setLoading( + void setLoading( LoadingState loadingStates, EventBus eventBus, bool isLoading) { loadingStates?.setLoading(key, isLoading); eventBus?.publish(LoadingStatusChangedEvent(key)); } // ignore: public_member_api_docs - void _sendLoadingEvent(LoadingState loadingStates, EventBus eventBus) { - _setLoading(loadingStates, eventBus, true); + void sendLoadingEvent(LoadingState loadingStates, EventBus eventBus) { + setLoading(loadingStates, eventBus, true); } // ignore: public_member_api_docs - void _sendLoadedEvent(LoadingState loadingStates, EventBus eventBus) { + void sendLoadedEvent(LoadingState loadingStates, EventBus eventBus) { eventBus?.publish(event); - _setLoading(loadingStates, eventBus, false); + setLoading(loadingStates, eventBus, false); } // ignore: public_member_api_docs void save() { - Static.storage.setString(key, _rawData); + Static.storage.setString(key, rawData); } // ignore: public_member_api_docs void clear() { - _rawData = null; - parsedData = null; - _loadedFromOnline = false; + rawData = null; + data = null; + loadedFromOnline = false; save(); } diff --git a/utils/lib/src/loading/tags_loader.dart b/utils/lib/src/loading/tags_loader.dart index 506d41c..340b71d 100644 --- a/utils/lib/src/loading/tags_loader.dart +++ b/utils/lib/src/loading/tags_loader.dart @@ -56,7 +56,7 @@ class TagsLoader extends Loader { ) async { if (tags == null) { await loadOnline(context, force: true); - tags = parsedData; + tags = data; } if (tags != null) { // Sync grade @@ -95,7 +95,7 @@ class TagsLoader extends Loader { {bool checkSync = true}) async { // Get all server tags... final status = await loadOnline(context, force: true); - final Tags allTags = parsedData; + final Tags allTags = data; if (allTags == null) { print('Failed to load tags: $status'); return reduceStatusCodes([status, StatusCode.failed]); diff --git a/utils/lib/src/theme.dart b/utils/lib/src/theme.dart index f55b260..c79b663 100644 --- a/utils/lib/src/theme.dart +++ b/utils/lib/src/theme.dart @@ -46,13 +46,13 @@ ThemeData get _darkTheme => ThemeData( color: _lightColor, ), ), - primaryIconTheme: IconThemeData( - color: Color(0xFFCCCCCC), - ), - cardTheme: _theme.cardTheme, - cardColor: darkColor, - backgroundColor: _darkBackgroundColor, - fontFamily: 'Ubuntu', + primaryIconTheme: IconThemeData( + color: Color(0xFFCCCCCC), + ), + cardTheme: _theme.cardTheme, + cardColor: darkColor, + backgroundColor: _darkBackgroundColor, + fontFamily: 'Ubuntu', ); // ignore: public_member_api_docs diff --git a/widgets/lib/src/custom_cached_network_image.dart b/widgets/lib/src/custom_cached_network_image.dart index 39fe3e0..e2a51fe 100644 --- a/widgets/lib/src/custom_cached_network_image.dart +++ b/widgets/lib/src/custom_cached_network_image.dart @@ -1,8 +1,8 @@ import 'dart:io'; +import 'dart:typed_data'; -import 'package:after_layout/after_layout.dart'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:utils/utils.dart'; @@ -10,10 +10,10 @@ import 'package:utils/utils.dart'; import 'custom_circular_progress_indicator.dart'; // ignore: public_member_api_docs -class CustomCachedNetworkImage extends StatefulWidget { +class CustomCachedNetworkImage extends StatelessWidget { // ignore: public_member_api_docs const CustomCachedNetworkImage({ - @required this.imageUrl, + @required this.provider, this.width, this.height, Key key, @@ -26,105 +26,107 @@ class CustomCachedNetworkImage extends StatefulWidget { final double height; // ignore: public_member_api_docs - final String imageUrl; - - @override - _CustomCachedNetworkImageState createState() => - _CustomCachedNetworkImageState(); -} - -class _CustomCachedNetworkImageState extends State - with AfterLayoutMixin { - bool _initialized = false; - String _cacheDir; - Platform _platform; - - @override - void afterFirstLayout(BuildContext context) { - if (Platform().isDesktop) { - getTemporaryDirectory().then((cacheDir) { - if (mounted) { - setState(() { - _cacheDir = cacheDir.path; - _initialized = true; - }); - } - }); - } - } - - @override - void initState() { - _platform = Platform(); - super.initState(); - } + final CustomCachedNetworkImageProvider provider; @override - Widget build(BuildContext context) { - final Widget loading = Container( - height: widget.height, - width: widget.width, - child: Center( - child: CustomCircularProgressIndicator( - width: widget.width / 2, - height: widget.height / 2, - ), - ), - ); - final Widget error = Icon( - Icons.error, - color: ThemeWidget.of(context).textColor, - ); - if (_platform.isMobile) { - return CachedNetworkImage( - imageUrl: widget.imageUrl, - height: widget.height, - width: widget.width, - placeholder: (context, url) => loading, - errorWidget: (context, url, error) => Icon(Icons.error_outline), - ); - } - if (_initialized || _platform.isWeb) { - Future future; - final path = - '$_cacheDir/${widget.imageUrl.split('//').sublist(1).join('//').replaceAll('/', '-')}'; - if (_platform.isDesktop) { - if (File(path).existsSync()) { - future = File(path).readAsBytes(); - } - } - future ??= Dio().get( - widget.imageUrl, - options: Options( - responseType: ResponseType.bytes, - ), - ); - return Center( - child: FutureBuilder( - future: future, + Widget build(BuildContext context) => Center( + child: FutureBuilder( + future: _loadImage(), builder: (context, snapshot) { if (snapshot.hasData) { - if (snapshot.data is Response) { - if (_platform.isDesktop) { - if (!Directory(_cacheDir).existsSync()) { - Directory(_cacheDir).createSync(recursive: true); - } - File(path).writeAsBytesSync(snapshot.data.data); - } - return Image.memory(snapshot.data.data); - } else { - return Image.memory(snapshot.data); - } + return SizedBox( + height: height, + width: width, + child: provider.imageWrapper(context, snapshot.data) ?? + Image.memory(snapshot.data), + ); } else if (snapshot.hasError) { print(snapshot.error); - return error; + return Icon( + Icons.error, + color: ThemeWidget.of(context).textColor, + ); } else { - return loading; + return Container( + height: height, + width: width, + child: Center( + child: CustomCircularProgressIndicator( + width: width != null ? width / 2 : null, + height: height != null ? height / 2 : null, + ), + ), + ); } }, ), ); + + Future _loadImage() async { + if (_CustomCachedNetworkImageCache._cache[provider.identifier] != null) { + return _CustomCachedNetworkImageCache._cache[provider.identifier]; + } else { + final cacheDir = await getTemporaryDirectory(); + final platform = Platform(); + final path = '${cacheDir.path}/${provider.identifier}'; + Uint8List data; + if (!platform.isWeb && File(path).existsSync()) { + data = await File(path).readAsBytes(); + } else { + data = await provider.loadImage(); + if (!platform.isWeb) { + if (!cacheDir.existsSync()) { + cacheDir.createSync(recursive: true); + } + File(path).writeAsBytesSync(data); + } + } + _CustomCachedNetworkImageCache._cache[provider.identifier] = data; + return data; } - return Container(); } } + +class _CustomCachedNetworkImageCache { + static final Map _cache = {}; +} + +// ignore: public_member_api_docs, one_member_abstracts +abstract class CustomCachedNetworkImageProvider { + // ignore: public_member_api_docs + Future loadImage(); + + // ignore: public_member_api_docs + Widget imageWrapper(BuildContext context, Uint8List image); + + // ignore: public_member_api_docs + String get identifier; +} + +// ignore: public_member_api_docs +class CustomCachedNetworkImageUrlProvider + implements CustomCachedNetworkImageProvider { + // ignore: public_member_api_docs + CustomCachedNetworkImageUrlProvider({ + @required this.imageUrl, + }); + + // ignore: public_member_api_docs + final String imageUrl; + + @override + Future loadImage() async => (await Dio().get( + imageUrl, + options: Options( + responseType: ResponseType.bytes, + ), + )) + .data; + + @override + String get identifier => + imageUrl.split('//').sublist(1).join('//').replaceAll('/', '-'); + + @override + Widget imageWrapper(BuildContext context, Uint8List image) => null; +} diff --git a/widgets/lib/src/custom_row.dart b/widgets/lib/src/custom_row.dart index 47a892b..66fd703 100644 --- a/widgets/lib/src/custom_row.dart +++ b/widgets/lib/src/custom_row.dart @@ -18,10 +18,6 @@ class CustomRow extends PreferredSize { @required this.title, this.subtitle, this.last, - this.titleColor, - this.titleFontWeight, - this.titleAlignment, - this.titleOverflow, this.splitColor, this.showSplit = true, this.heroTag, @@ -31,9 +27,8 @@ class CustomRow extends PreferredSize { /// The widget left of the split final Widget leading; - /// The row title in the [titleColor] - /// and with [titleFontWeight], [titleOverflow] and [titleAlignment] - final String title; + /// The row title + final Widget title; /// The widget directly below the title final Widget subtitle; @@ -41,18 +36,6 @@ class CustomRow extends PreferredSize { /// The widget on the right end of the row final Widget last; - // ignore: public_member_api_docs - final Color titleColor; - - // ignore: public_member_api_docs - final FontWeight titleFontWeight; - - // ignore: public_member_api_docs - final CrossAxisAlignment titleAlignment; - - // ignore: public_member_api_docs - final TextOverflow titleOverflow; - // ignore: public_member_api_docs final Color splitColor; @@ -70,10 +53,9 @@ class CustomRow extends PreferredSize { @override Widget build(BuildContext context) => Container( - height: customRowHeight - 20, - margin: hasMargin ? EdgeInsets.all(customRowVerticalMargin / 2) : null, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + margin: hasMargin ? EdgeInsets.all(customRowVerticalMargin / 2) : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ if (leading != null) Container( @@ -85,34 +67,25 @@ class CustomRow extends PreferredSize { if (showSplit ?? true) Container( height: customRowHeight - 26, - width: 2.5, - margin: EdgeInsets.only( - right: 5, - ), - color: splitColor ?? Colors.transparent, - ), - Expanded( - flex: 1, - child: Column( - crossAxisAlignment: titleAlignment ?? CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (title != null) - Text( - title, - style: TextStyle( - fontSize: 17, - fontWeight: titleFontWeight, - color: titleColor ?? Theme.of(context).accentColor, - ), - overflow: titleOverflow ?? TextOverflow.ellipsis, + width: 2.5, + margin: EdgeInsets.only( + right: 5, ), - if (subtitle != null) subtitle, - ], - ), + color: splitColor ?? Colors.transparent, + ), + Expanded( + flex: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (title != null) title, + if (subtitle != null) subtitle, + ], + ), + ), + if (last != null) last, + ], ), - if (last != null) last, - ], - ), - ); + ); } diff --git a/widgets/lib/src/dialog_content_wrapper.dart b/widgets/lib/src/dialog_content_wrapper.dart index 49b2b46..3fe74a1 100644 --- a/widgets/lib/src/dialog_content_wrapper.dart +++ b/widgets/lib/src/dialog_content_wrapper.dart @@ -31,9 +31,9 @@ class DialogContentWrapper extends StatelessWidget { : null, child: Container( padding: EdgeInsets.only( - left: 12.5, - right: 12.5, - bottom: 7.5, + left: 15, + right: 15, + bottom: 20, ), child: Column( mainAxisAlignment: spread diff --git a/widgets/lib/src/size_limit.dart b/widgets/lib/src/size_limit.dart index 0a95938..f214417 100644 --- a/widgets/lib/src/size_limit.dart +++ b/widgets/lib/src/size_limit.dart @@ -5,18 +5,22 @@ class SizeLimit extends StatelessWidget { // ignore: public_member_api_docs const SizeLimit({ @required this.child, + this.maxWidth = 700, Key key, }) : super(key: key); // ignore: public_member_api_docs final Widget child; + // ignore: public_member_api_docs + final double maxWidth; + @override Widget build(BuildContext context) => Column( children: [ Container( constraints: BoxConstraints( - maxWidth: 700, + maxWidth: maxWidth, ), child: child, ), diff --git a/widgets/pubspec.yaml b/widgets/pubspec.yaml index 37e0005..c221118 100644 --- a/widgets/pubspec.yaml +++ b/widgets/pubspec.yaml @@ -9,7 +9,6 @@ dependencies: material_design_icons_flutter: flutter_event_bus: after_layout: - cached_network_image: dio: path_provider: utils: