diff --git a/lib/apis/session_api.dart b/lib/apis/session_api.dart new file mode 100644 index 0000000..27b15b6 --- /dev/null +++ b/lib/apis/session_api.dart @@ -0,0 +1,23 @@ + +import 'dart:convert'; + +import 'package:contacts_plus_plus/clients/api_client.dart'; +import 'package:contacts_plus_plus/models/session.dart'; + +class SessionApi { + static Future> getSessions(ApiClient client, {DateTime? updatedSince, bool includeEnded = false, + String name = "", String hostName = "", String hostId = "", int minActiveUsers = 0, bool includeEmptyHeadless = true, + }) async { + final query = "?includeEnded=$includeEnded" + "&includeEmptyHeadless=$includeEmptyHeadless" + "&minActiveUsers=$minActiveUsers" + "${updatedSince == null ? "" : "&updatedSince=${updatedSince.toIso8601String()}"}" + "${name.isEmpty ? "" : "&name=$name"}" + "${hostName.isEmpty ? "" : "&hostName=$hostName"}" + "${hostId.isEmpty ? "" : "&hostId=$hostId"}"; + final response = await client.get("/sessions$query"); + ApiClient.checkResponse(response); + final body = jsonDecode(response.body) as List; + return body.map((e) => Session.fromMap(e)).toList(); + } +} \ No newline at end of file diff --git a/lib/auxiliary.dart b/lib/auxiliary.dart index 027f341..d74f905 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:contacts_plus_plus/config.dart'; +import 'package:flutter/material.dart'; import 'package:html/parser.dart' as htmlparser; import 'package:uuid/uuid.dart'; @@ -44,15 +45,41 @@ extension Unique on List { } } -extension Strip on String { +extension StringX on String { + String stripHtml() { final document = htmlparser.parse(this); - return htmlparser.parse(document.body?.text).documentElement?.text ?? ""; + return htmlparser + .parse(document.body?.text) + .documentElement + ?.text ?? ""; } // This won't be accurate since userIds can't contain certain characters that usernames can // but it's fine for just having a name to display String stripUid() => startsWith("U-") ? substring(2) : this; + + String get overflow => + Characters(this) + .replaceAll(Characters(''), Characters('\u{200B}')) + .toString(); + + bool looseMatch(String other) { + if (other.isEmpty) return true; + var index = 0; + for (final needleChar in other.characters) { + if (index >= characters.length) return false; + for (; index < characters.length; index++) { + if (needleChar.toLowerCase() == characters.elementAt(index).toLowerCase()) break; + } + } + if (index < characters.length) { + return true; + } else if (characters.last.toLowerCase() == other.characters.last.toLowerCase()) { + return true; + } + return false; + } } extension Format on Duration { diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index e52f86e..9173524 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -6,6 +6,7 @@ import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:contacts_plus_plus/models/authentication_data.dart'; +import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import '../config.dart'; @@ -17,9 +18,10 @@ class ApiClient { static const String tokenKey = "token"; static const String passwordKey = "password"; - const ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; + ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; final AuthenticationData _authenticationData; + final Logger _logger = Logger("ApiClient"); AuthenticationData get authenticationData => _authenticationData; String get userId => _authenticationData.userId; @@ -137,6 +139,7 @@ class ApiClient { Future get(String path, {Map? headers}) { headers ??= {}; headers.addAll(authorizationHeader); + _logger.info("GET: $path"); return http.get(buildFullUri(path), headers: headers); } @@ -144,6 +147,7 @@ class ApiClient { headers ??= {}; headers["Content-Type"] = "application/json"; headers.addAll(authorizationHeader); + _logger.info("PST: $path"); return http.post(buildFullUri(path), headers: headers, body: body); } @@ -151,18 +155,21 @@ class ApiClient { headers ??= {}; headers["Content-Type"] = "application/json"; headers.addAll(authorizationHeader); + _logger.info("PUT: $path"); return http.put(buildFullUri(path), headers: headers, body: body); } Future delete(String path, {Map? headers}) { headers ??= {}; headers.addAll(authorizationHeader); + _logger.info("DEL: $path"); return http.delete(buildFullUri(path), headers: headers); } Future patch(String path, {Map? headers}) { headers ??= {}; headers.addAll(authorizationHeader); + _logger.info("PAT: $path"); return http.patch(buildFullUri(path), headers: headers); } } diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index ced883c..70673b1 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -12,12 +12,14 @@ import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/settings.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hive/hive.dart'; import 'package:http/http.dart' as http; import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/config.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:workmanager/workmanager.dart'; enum EventType { @@ -54,10 +56,10 @@ class MessagingClient extends ChangeNotifier { static const String unreadCheckTaskName = "periodic-unread-check"; static const String _storageNotifiedUnreadsKey = "notfiedUnreads"; static const String _storageLastUpdateKey = "lastUnreadCheck"; + static const String _hiveKey = "mClient"; + static const String _storedFriendsKey = "friends"; static const FlutterSecureStorage _storage = FlutterSecureStorage(); - static const Duration _autoRefreshDuration = Duration(seconds: 90); - static const Duration _refreshTimeoutDuration = Duration(seconds: 30); final ApiClient _apiClient; final Map _friendsCache = {}; @@ -86,16 +88,27 @@ class MessagingClient extends ChangeNotifier { MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient, required SettingsClient settingsClient}) : _apiClient = apiClient, _notificationClient = notificationClient, _settingsClient = settingsClient { - refreshFriendsListWithErrorHandler(); + initBox().whenComplete(() async { + try { + await _restoreFriendsList(); + try { + await refreshFriendsList(); + } catch (_) { + notifyListeners(); + } + } catch (e) { + refreshFriendsListWithErrorHandler(); + } + }); startWebsocket(); _notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async { // We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this // but I don't feel like implementing that right now. UserApi.setStatus(apiClient, status: await UserApi.getUserStatus(apiClient, userId: apiClient.userId)); }); - _settingsClient.addListener(onSettingsChanged); + //_settingsClient.addListener(onSettingsChanged); if (!_settingsClient.currentSettings.notificationsDenied.valueOrDefault) { - registerNotificationTask(); + //registerNotificationTask(); } } @@ -123,6 +136,7 @@ class MessagingClient extends ChangeNotifier { static Future backgroundCheckUnreads(Map? inputData) async { if (inputData == null) throw "Unauthenticated"; + return; final auth = AuthenticationData.fromMap(inputData); const storage = FlutterSecureStorage(); final lastCheckData = await storage.read(key: _storageLastUpdateKey); @@ -178,7 +192,7 @@ class MessagingClient extends ChangeNotifier { notifyListeners(); } - void refreshFriendsListWithErrorHandler () async { + void refreshFriendsListWithErrorHandler() async { try { await refreshFriendsList(); } catch (e) { @@ -187,9 +201,33 @@ class MessagingClient extends ChangeNotifier { } } - Future refreshFriendsList() async { - if (_refreshTimeout?.isActive == true) return; + Future initBox() async { + try { + final path = await getTemporaryDirectory(); + Hive.init(path.path); + await Hive.openBox(_hiveKey, path: path.path); + } catch (_) {} + } + Future _restoreFriendsList() async { + if (!Hive.isBoxOpen(_hiveKey)) throw "Failed to open box"; + final mStorage = Hive.box(_hiveKey); + final storedFriends = await mStorage.get(_storedFriendsKey) as List?; + if (storedFriends == null) throw "No cached friends list"; + _friendsCache.clear(); + _sortedFriendsCache.clear(); + + for (final storedFriend in storedFriends) { + final friend = Friend.fromMap(storedFriend); + _friendsCache[friend.id] = friend; + _sortedFriendsCache.add(friend); + } + _sortFriendsCache(); + notifyListeners(); + } + + + Future refreshFriendsList() async { _autoRefresh?.cancel(); _autoRefresh = Timer(_autoRefreshDuration, () async { try { @@ -199,23 +237,29 @@ class MessagingClient extends ChangeNotifier { // just keep showing the old state until refreshing succeeds. } }); - _refreshTimeout?.cancel(); - _refreshTimeout = Timer(_refreshTimeoutDuration, () {}); - - final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true); - updateAllUnreads(unreadMessages.toList()); + final now = DateTime.now(); + final lastUpdate = await _storage.read(key: _storageLastUpdateKey); + if (lastUpdate != null && now.difference(DateTime.parse(lastUpdate)) < _autoRefreshDuration) throw "You are being rate limited."; final friends = await FriendApi.getFriendsList(_apiClient); + final List storableFriends = []; _friendsCache.clear(); for (final friend in friends) { _friendsCache[friend.id] = friend; + storableFriends.add(friend.toMap(shallow: true)); } _sortedFriendsCache.clear(); _sortedFriendsCache.addAll(friends); _sortFriendsCache(); _initStatus = ""; - await _storage.write(key: _storageLastUpdateKey, value: DateTime.now().toIso8601String()); + await _storage.write(key: _storageLastUpdateKey, value: now.toIso8601String()); + final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true); + updateAllUnreads(unreadMessages.toList()); + notifyListeners(); + if (!Hive.isBoxOpen(_hiveKey)) return; + final mStorage = Hive.box(_hiveKey); + mStorage.put(_storedFriendsKey, storableFriends); } void _sortFriendsCache() { diff --git a/lib/main.dart b/lib/main.dart index c6bb245..bcc9495 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,14 +6,13 @@ import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/settings_client.dart'; import 'package:contacts_plus_plus/models/sem_ver.dart'; -import 'package:contacts_plus_plus/widgets/friends/friends_list.dart'; +import 'package:contacts_plus_plus/widgets/home.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:contacts_plus_plus/widgets/update_notifier.dart'; import 'package:flutter/material.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:logging/logging.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; import 'package:workmanager/workmanager.dart'; import 'models/authentication_data.dart'; @@ -42,8 +41,8 @@ void main() async { @pragma('vm:entry-point') // Mandatory if the App is obfuscated or using Flutter 3.1+ void callbackDispatcher() { + return; Workmanager().executeTask((String task, Map? inputData) async { - return Future.value(true); //Disable background tasks for now debugPrint("Native called background task: $task"); //simpleTask will be emitted here. if (task == MessagingClient.unreadCheckTaskName) { try { @@ -134,29 +133,22 @@ class _ContactsPlusPlusState extends State { colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) ), home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder - builder: (context) { - showUpdateDialogOnFirstBuild(context); - final clientHolder = ClientHolder.of(context); - return _authData.isAuthenticated ? - ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. - create: (context) => - MessagingClient( - apiClient: clientHolder.apiClient, - notificationClient: clientHolder.notificationClient, - settingsClient: widget.settingsClient, - ), - child: const FriendsList(), - ) : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); - } - }, - ); - } + builder: (context) { + showUpdateDialogOnFirstBuild(context); + if (_authData.isAuthenticated) { + return const Home(); + } else { + return LoginScreen( + onLoginSuccessful: (AuthenticationData authData) async { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, + ); + } + } ) ), ); diff --git a/lib/models/friend.dart b/lib/models/friend.dart index c26799e..adceacb 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -18,7 +18,7 @@ class Friend extends Comparable { factory Friend.fromMap(Map map) { return Friend( id: map["id"], - username: map["friendUsername"], + username: map["friendUsername"] ?? map["username"], ownerId: map["ownerId"] ?? map["id"], userStatus: UserStatus.fromMap(map["userStatus"]), userProfile: UserProfile.fromMap(map["profile"] ?? {}), @@ -140,7 +140,7 @@ class UserStatus { Map toMap({bool shallow=false}) { return { - "onlineStatus": onlineStatus.index, + "onlineStatus": onlineStatus.name, "lastStatusChange": lastStatusChange.toIso8601String(), "activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),), "neosVersion": neosVersion, diff --git a/lib/string_formatter.dart b/lib/string_formatter.dart new file mode 100644 index 0000000..345fce7 --- /dev/null +++ b/lib/string_formatter.dart @@ -0,0 +1,212 @@ +import 'package:color/color.dart' as cc; +import 'package:flutter/material.dart'; + +class FormatNode { + String? text; + final FormatData format; + final List children; + + FormatNode({this.text, required this.format, required this.children}); + + TextSpan toTextSpan({required TextStyle baseStyle}) { + return TextSpan( + text: text, + style: format.isUnformatted ? baseStyle : format.style(baseStyle), + children: children.map((e) => e.toTextSpan(baseStyle: baseStyle)).toList() + ); + } + + static FormatNode buildFromStyles(List styles, String text) { + if (styles.isEmpty) return FormatNode(format: FormatData.unformatted(), children: [], text: text); + final root = FormatNode(format: styles.first, children: []); + var current = root; + for (final style in styles.sublist(1)) { + final next = FormatNode(format: style, children: []); + current.children.add(next); + current = next; + } + current.text = text; + return root; + } +} + +class StringFormatter { + static TextSpan? tryFormat(String text, {TextStyle? baseStyle}) { + try { + final content = StringFormatter.format(text, baseStyle: baseStyle); + if ((content.children?.isEmpty ?? true) && content.style == null) { + return null; + } + return content; + } catch (e) { + rethrow; + } + } + + static TextSpan format(String text, {TextStyle? baseStyle}) { + baseStyle ??= const TextStyle(); + var tags = parseTags(text); + if (tags.isEmpty) return TextSpan(text: text, style: null, children: const []); + final root = FormatNode( + format: FormatData.unformatted(), + text: text.substring(0, tags.first.startIndex), + children: [], + ); + + final activeTags = []; + + for (int i = 0; i < tags.length; i++) { + final tag = tags[i]; + final substr = text.substring(tag.endIndex, (i + 1 < tags.length) ? tags[i + 1].startIndex : null); + if (tag.format.isAdditive) { + activeTags.add(tag.format); + } else { + final idx = activeTags.lastIndexWhere((element) => element.name == tag.format.name); + if (idx != -1) { + activeTags.removeAt(idx); + } + } + if (substr.isNotEmpty) { + root.children.add( + FormatNode.buildFromStyles(activeTags, substr) + ); + } + } + return root.toTextSpan(baseStyle: baseStyle); + } + + static List parseTags(String text) { + final startMatches = RegExp(r"<(.+?)>").allMatches(text); + + final spans = []; + + for (final startMatch in startMatches) { + final fullTag = startMatch.group(1); + if (fullTag == null) continue; + final tag = FormatData.parse(fullTag); + spans.add( + FormatTag( + startIndex: startMatch.start, + endIndex: startMatch.end, + format: tag, + ) + ); + } + return spans; + } +} + +class FormatTag { + final int startIndex; + final int endIndex; + final FormatData format; + + const FormatTag({ + required this.startIndex, + required this.endIndex, + required this.format, + }); +} + +class FormatAction { + final String Function(String input, String parameter)? transform; + final TextStyle Function(String? parameter, TextStyle baseStyle)? style; + + FormatAction({this.transform, this.style}); +} + +class FormatData { + static Color? tryParseColor(String? text) { + if (text == null) return null; + var color = cc.RgbColor.namedColors[text]; + if (color != null) { + return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round()); + } + try { + color = cc.HexColor(text); + return Color.fromARGB(255, color.r.round(), color.g.round(), color.b.round()); + } catch (_) { + return null; + } + } + + static final Map _richTextTags = { + "align": FormatAction(), + "alpha": FormatAction(style: (param, baseStyle) { + if (param == null || !param.startsWith("#")) return baseStyle; + final alpha = int.tryParse(param.substring(1), radix: 16); + if (alpha == null) return baseStyle; + return baseStyle.copyWith(color: baseStyle.color?.withAlpha(alpha)); + }), + "color": FormatAction(style: (param, baseStyle) { + if (param == null) return baseStyle; + final color = tryParseColor(param); + if (color == null) return baseStyle; + return baseStyle.copyWith(color: color); + }), + "b": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontWeight: FontWeight.bold)), + "br": FormatAction(transform: (text, param) => "\n$text"), + "i": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(fontStyle: FontStyle.italic)), + "cspace": FormatAction(), + "font": FormatAction(), + "indent": FormatAction(), + "line-height": FormatAction(), + "line-indent": FormatAction(), + "link": FormatAction(), + "lowercase": FormatAction(), + "uppercase": FormatAction(), + "smallcaps": FormatAction(), + "margin": FormatAction(), + "mark": FormatAction(style: (param, baseStyle) { + if (param == null) return baseStyle; + final color = tryParseColor(param); + if (color == null) return baseStyle; + return baseStyle.copyWith(backgroundColor: color); + }), + "mspace": FormatAction(), + "noparse": FormatAction(), + "nobr": FormatAction(), + "page": FormatAction(), + "pos": FormatAction(), + "size": FormatAction(), + "space": FormatAction(), + "sprite": FormatAction(), + "s": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.lineThrough)), + "u": FormatAction(style: (param, baseStyle) => baseStyle.copyWith(decoration: TextDecoration.underline)), + "style": FormatAction(), + "sub": FormatAction(), + "sup": FormatAction(), + "voffset": FormatAction(), + "width": FormatAction(), + }; + + final String name; + final String parameter; + final bool isAdditive; + + const FormatData({required this.name, required this.parameter, required this.isAdditive}); + + factory FormatData.parse(String text) { + if (text.contains("/")) return FormatData(name: text.replaceAll("/", ""), parameter: "", isAdditive: false); + final sepIdx = text.indexOf("="); + if (sepIdx == -1) { + return FormatData(name: text, parameter: "", isAdditive: true); + } else { + return FormatData( + name: text.substring(0, sepIdx).trim().toLowerCase(), + parameter: text.substring(sepIdx + 1, text.length).trim().toLowerCase(), + isAdditive: true, + ); + } + } + + factory FormatData.unformatted() => const FormatData(name: "", parameter: "", isAdditive: false); + + bool get isUnformatted => name.isEmpty && parameter.isEmpty && !isAdditive; + + bool get isValid => _richTextTags.containsKey(name); + + String? apply(String? text) => text == null ? null : _richTextTags[name]?.transform?.call(text, parameter); + + TextStyle style(TextStyle baseStyle) => _richTextTags[name]?.style?.call(parameter, baseStyle) ?? baseStyle; +} \ No newline at end of file diff --git a/lib/widgets/friends/friends_list.dart b/lib/widgets/friends/friends_list.dart index f4591e9..a83429f 100644 --- a/lib/widgets/friends/friends_list.dart +++ b/lib/widgets/friends/friends_list.dart @@ -24,234 +24,20 @@ class MenuItemDefinition { const MenuItemDefinition({required this.name, required this.icon, required this.onTap}); } -class FriendsList extends StatefulWidget { +class FriendsList extends StatefulWidget { const FriendsList({super.key}); @override State createState() => _FriendsListState(); } -class _FriendsListState extends State { - Future? _userProfileFuture; - Future? _userStatusFuture; - ClientHolder? _clientHolder; +class _FriendsListState extends State with AutomaticKeepAliveClientMixin { String _searchFilter = ""; - @override - void didChangeDependencies() async { - super.didChangeDependencies(); - final clientHolder = ClientHolder.of(context); - if (_clientHolder != clientHolder) { - _clientHolder = clientHolder; - final apiClient = _clientHolder!.apiClient; - _userProfileFuture = UserApi.getPersonalProfile(apiClient); - _refreshUserStatus(); - } - } - - void _refreshUserStatus() { - final apiClient = _clientHolder!.apiClient; - _userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async { - if (value.onlineStatus == OnlineStatus.offline) { - final newStatus = value.copyWith( - onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus - .valueOrDefault] - ); - await UserApi.setStatus(apiClient, status: newStatus); - return newStatus; - } - return value; - }); - } - @override Widget build(BuildContext context) { - final clientHolder = ClientHolder.of(context); - return Scaffold( - appBar: AppBar( - title: const Text("Contacts++"), - actions: [ - FutureBuilder( - future: _userStatusFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - final userStatus = snapshot.data as UserStatus; - return PopupMenuButton( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,), - ), - Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), - ], - ), - onSelected: (OnlineStatus onlineStatus) async { - try { - final newStatus = userStatus.copyWith(onlineStatus: onlineStatus); - setState(() { - _userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now())); - }); - final settingsClient = ClientHolder - .of(context) - .settingsClient; - await UserApi.setStatus(clientHolder.apiClient, status: newStatus); - await settingsClient.changeSettings( - settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index)); - } catch (e, s) { - FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text( - "Failed to set online-status."))); - setState(() { - _userStatusFuture = Future.value(userStatus); - }); - } - }, - itemBuilder: (BuildContext context) => - OnlineStatus.values.where((element) => - element == OnlineStatus.online - || element == OnlineStatus.invisible).map((item) => - PopupMenuItem( - value: item, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.circle, size: 16, color: item.color,), - const SizedBox(width: 8,), - Text(toBeginningOfSentenceCase(item.name)!), - ], - ), - ), - ).toList()); - } else if (snapshot.hasError) { - return TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: Theme - .of(context) - .colorScheme - .onSurface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2) - ), - onPressed: () { - setState(() { - _userStatusFuture = null; - }); - setState(() { - _userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient - .userId); - }); - }, - icon: const Icon(Icons.warning), - label: const Text("Retry"), - ); - } else { - return TextButton.icon( - style: TextButton.styleFrom( - disabledForegroundColor: Theme - .of(context) - .colorScheme - .onSurface, - ), - onPressed: null, - icon: Container( - width: 16, - height: 16, - margin: const EdgeInsets.only(right: 4), - child: CircularProgressIndicator( - strokeWidth: 2, - color: Theme - .of(context) - .colorScheme - .onSurface, - ), - ), - label: const Text("Loading"), - ); - } - } - ), - Padding( - padding: const EdgeInsets.only(left: 4, right: 4), - child: PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (MenuItemDefinition itemDef) async { - await itemDef.onTap(); - }, - itemBuilder: (BuildContext context) => - [ - MenuItemDefinition( - name: "Settings", - icon: Icons.settings, - onTap: () async { - await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage())); - }, - ), - MenuItemDefinition( - name: "Find Users", - icon: Icons.person_add, - onTap: () async { - final mClient = Provider.of(context, listen: false); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - ChangeNotifierProvider.value( - value: mClient, - child: const UserSearch(), - ), - ), - ); - }, - ), - MenuItemDefinition( - name: "My Profile", - icon: Icons.person, - onTap: () async { - await showDialog( - context: context, - builder: (context) { - return FutureBuilder( - future: _userProfileFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - final profile = snapshot.data as PersonalProfile; - return MyProfileDialog(profile: profile); - } else if (snapshot.hasError) { - return DefaultErrorWidget( - title: "Failed to load personal profile.", - onRetry: () { - setState(() { - _userProfileFuture = UserApi.getPersonalProfile(ClientHolder - .of(context) - .apiClient); - }); - }, - ); - } else { - return const Center(child: CircularProgressIndicator(),); - } - } - ); - }, - ); - }, - ), - ].map((item) => - PopupMenuItem( - value: item, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(item.name), - Icon(item.icon), - ], - ), - ), - ).toList(), - ), - ) - ], - ), - body: Stack( + super.build(context); + return Stack( alignment: Alignment.topCenter, children: [ Consumer( @@ -311,7 +97,237 @@ class _FriendsListState extends State { ), ), ], - ), + ); + } + + @override + // TODO: implement wantKeepAlive + bool get wantKeepAlive => true; +} + +class FriendsListAppBar extends StatefulWidget implements PreferredSizeWidget { + const FriendsListAppBar({super.key}); + + @override + State createState() => _FriendsListAppBarState(); + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} + +class _FriendsListAppBarState extends State { + Future? _userStatusFuture; + Future? _userProfileFuture; + ClientHolder? _clientHolder; + + @override + void didChangeDependencies() async { + super.didChangeDependencies(); + final clientHolder = ClientHolder.of(context); + if (_clientHolder != clientHolder) { + _clientHolder = clientHolder; + final apiClient = _clientHolder!.apiClient; + _userProfileFuture = UserApi.getPersonalProfile(apiClient); + _refreshUserStatus(); + } + } + + void _refreshUserStatus() { + final apiClient = _clientHolder!.apiClient; + _userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async { + if (value.onlineStatus == OnlineStatus.offline) { + final newStatus = value.copyWith( + onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus + .valueOrDefault] + ); + await UserApi.setStatus(apiClient, status: newStatus); + return newStatus; + } + return value; + }); + } + + @override + Widget build(BuildContext context) { + final clientHolder = ClientHolder.of(context); + return AppBar( + title: const Text("Contacts++"), + actions: [ + FutureBuilder( + future: _userStatusFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final userStatus = snapshot.data as UserStatus; + return PopupMenuButton( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,), + ), + Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), + ], + ), + onSelected: (OnlineStatus onlineStatus) async { + try { + final newStatus = userStatus.copyWith(onlineStatus: onlineStatus); + setState(() { + _userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now())); + }); + final settingsClient = clientHolder.settingsClient; + await UserApi.setStatus(clientHolder.apiClient, status: newStatus); + await settingsClient.changeSettings( + settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index)); + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text( + "Failed to set online-status."))); + setState(() { + _userStatusFuture = Future.value(userStatus); + }); + } + }, + itemBuilder: (BuildContext context) => + OnlineStatus.values.where((element) => + element == OnlineStatus.online + || element == OnlineStatus.invisible).map((item) => + PopupMenuItem( + value: item, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.circle, size: 16, color: item.color,), + const SizedBox(width: 8,), + Text(toBeginningOfSentenceCase(item.name)!), + ], + ), + ), + ).toList()); + } else if (snapshot.hasError) { + return TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2) + ), + onPressed: () { + setState(() { + _userStatusFuture = null; + }); + setState(() { + _userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient + .userId); + }); + }, + icon: const Icon(Icons.warning), + label: const Text("Retry"), + ); + } else { + return TextButton.icon( + style: TextButton.styleFrom( + disabledForegroundColor: Theme + .of(context) + .colorScheme + .onSurface, + ), + onPressed: null, + icon: Container( + width: 16, + height: 16, + margin: const EdgeInsets.only(right: 4), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme + .of(context) + .colorScheme + .onSurface, + ), + ), + label: const Text("Loading"), + ); + } + } + ), + Padding( + padding: const EdgeInsets.only(left: 4, right: 4), + child: PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (MenuItemDefinition itemDef) async { + await itemDef.onTap(); + }, + itemBuilder: (BuildContext context) => + [ + MenuItemDefinition( + name: "Settings", + icon: Icons.settings, + onTap: () async { + await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage())); + }, + ), + MenuItemDefinition( + name: "Find Users", + icon: Icons.person_add, + onTap: () async { + final mClient = Provider.of(context, listen: false); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + ChangeNotifierProvider.value( + value: mClient, + child: const UserSearch(), + ), + ), + ); + }, + ), + MenuItemDefinition( + name: "My Profile", + icon: Icons.person, + onTap: () async { + await showDialog( + context: context, + builder: (context) { + return FutureBuilder( + future: _userProfileFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final profile = snapshot.data as PersonalProfile; + return MyProfileDialog(profile: profile); + } else if (snapshot.hasError) { + return DefaultErrorWidget( + title: "Failed to load personal profile.", + onRetry: () { + setState(() { + _userProfileFuture = UserApi.getPersonalProfile(clientHolder.apiClient); + }); + }, + ); + } else { + return const Center(child: CircularProgressIndicator(),); + } + } + ); + }, + ); + }, + ), + ].map((item) => + PopupMenuItem( + value: item, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.name), + Icon(item.icon), + ], + ), + ), + ).toList(), + ), + ) + ], ); } } \ No newline at end of file diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart new file mode 100644 index 0000000..a577814 --- /dev/null +++ b/lib/widgets/home.dart @@ -0,0 +1,77 @@ + +import 'package:contacts_plus_plus/client_holder.dart'; +import 'package:contacts_plus_plus/clients/messaging_client.dart'; +import 'package:contacts_plus_plus/widgets/friends/friends_list.dart'; +import 'package:contacts_plus_plus/widgets/sessions/sessions_list.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class Home extends StatefulWidget { + const Home({super.key}); + + @override + State createState() => _HomeState(); +} + +class _HomeState extends State with AutomaticKeepAliveClientMixin { + final PageController _pageController = PageController(initialPage: 1); + ClientHolder? _clientHolder; + MessagingClient? _mClient; + int _currentPageIndex = 1; + + @override + void didChangeDependencies() async { + super.didChangeDependencies(); + final clientHolder = ClientHolder.of(context); + if (_clientHolder != clientHolder) { + _clientHolder = clientHolder; + _mClient = MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + settingsClient: clientHolder.settingsClient, + ); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Scaffold( + bottomNavigationBar: NavigationBar( + selectedIndex: _currentPageIndex, + onDestinationSelected: (int index) async { + setState(() { + _currentPageIndex = index; + }); + await _pageController.animateToPage(index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCirc, + ); + }, + destinations: const [ + NavigationDestination(icon: Icon(Icons.folder_copy), label: "Inventory"), + NavigationDestination(icon: Icon(Icons.chat), label: "Contacts"), + NavigationDestination(icon: Icon(Icons.location_city), label: "Sessions") + ], + ), + appBar: const FriendsListAppBar(), + body: PageView( + physics: const NeverScrollableScrollPhysics(), + controller: _pageController, + children: [ + const Center(child: Text("Not implemented yet"),), + ChangeNotifierProvider + .value( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. + value: _mClient, + child: const FriendsList(), + ), + const SessionsList(), + ], + ) + ); + } + + @override + // TODO: implement wantKeepAlive + bool get wantKeepAlive => true; +} \ No newline at end of file diff --git a/lib/widgets/messages/message_bubble.dart b/lib/widgets/messages/message_bubble.dart index d8dd2f9..d9ab2c0 100644 --- a/lib/widgets/messages/message_bubble.dart +++ b/lib/widgets/messages/message_bubble.dart @@ -1,4 +1,5 @@ import 'package:contacts_plus_plus/models/message.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; import 'package:contacts_plus_plus/widgets/messages/message_asset.dart'; import 'package:contacts_plus_plus/widgets/messages/message_audio_player.dart'; import 'package:contacts_plus_plus/widgets/messages/message_session_invite.dart'; @@ -60,6 +61,13 @@ class MyMessageBubble extends StatelessWidget { ); case MessageType.unknown: case MessageType.text: + final formatted = StringFormatter.tryFormat( + message.content, + baseStyle: Theme + .of(context) + .textTheme + .bodyLarge, + ); return Row( mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, @@ -79,7 +87,7 @@ class MyMessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( + formatted == null ? Text( message.content, softWrap: true, maxLines: null, @@ -87,7 +95,7 @@ class MyMessageBubble extends StatelessWidget { .of(context) .textTheme .bodyLarge, - ), + ) : RichText(text: formatted, maxLines: null, softWrap: true,), const SizedBox(height: 6,), Row( mainAxisSize: MainAxisSize.min, @@ -148,7 +156,6 @@ class OtherMessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { - var content = message.content; switch (message.type) { case MessageType.sessionInvite: return Row( @@ -193,8 +200,14 @@ class OtherMessageBubble extends StatelessWidget { ], ); case MessageType.unknown: - rawText: case MessageType.text: + final formatted = StringFormatter.tryFormat( + message.content, + baseStyle: Theme + .of(context) + .textTheme + .bodyLarge, + ); return Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, @@ -214,15 +227,15 @@ class OtherMessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - content, + formatted == null ? Text( + message.content, softWrap: true, maxLines: null, style: Theme .of(context) .textTheme .bodyLarge, - ), + ) : RichText(text: formatted, maxLines: null, softWrap: true,), const SizedBox(height: 6,), Text( _dateFormat.format(message.sendTime), diff --git a/lib/widgets/messages/message_session_invite.dart b/lib/widgets/messages/message_session_invite.dart index a9dafdc..ed86d87 100644 --- a/lib/widgets/messages/message_session_invite.dart +++ b/lib/widgets/messages/message_session_invite.dart @@ -4,6 +4,7 @@ import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/session.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; @@ -18,6 +19,7 @@ class MessageSessionInvite extends StatelessWidget { @override Widget build(BuildContext context) { final sessionInfo = Session.fromMap(jsonDecode(message.content)); + final formattedName = StringFormatter.tryFormat(sessionInfo.name, baseStyle: Theme.of(context).textTheme.titleMedium); return TextButton( onPressed: () { showDialog(context: context, builder: (context) => SessionPopup(session: sessionInfo)); @@ -38,7 +40,8 @@ class MessageSessionInvite extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.only(top: 4), - child: Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,), + child: formattedName != null ? RichText(text: formattedName, maxLines: null, softWrap: true) : + Text(sessionInfo.name, maxLines: null, softWrap: true, style: Theme.of(context).textTheme.titleMedium,), ), ), Padding( diff --git a/lib/widgets/messages/messages_session_header.dart b/lib/widgets/messages/messages_session_header.dart index 6ae3b1d..4a791e0 100644 --- a/lib/widgets/messages/messages_session_header.dart +++ b/lib/widgets/messages/messages_session_header.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/session.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:flutter/material.dart'; @@ -13,6 +14,7 @@ class SessionPopup extends StatelessWidget { Widget build(BuildContext context) { final ScrollController userListScrollController = ScrollController(); final thumbnailUri = Aux.neosDbToHttp(session.thumbnail); + final formattedTitle = StringFormatter.tryFormat(session.name); return Dialog( insetPadding: const EdgeInsets.all(32), child: Container( @@ -30,7 +32,8 @@ class SessionPopup extends StatelessWidget { Expanded( child: ListView( children: [ - Text(session.name, style: Theme.of(context).textTheme.titleMedium), + formattedTitle == null ? + Text(session.name, style: Theme.of(context).textTheme.titleMedium) : RichText(text: formattedTitle), Text(session.description.isEmpty ? "No description." : session.description, style: Theme.of(context).textTheme.labelMedium), Text("Tags: ${session.tags.isEmpty ? "None" : session.tags.join(", ")}", style: Theme.of(context).textTheme.labelMedium, @@ -114,6 +117,7 @@ class SessionTile extends StatelessWidget { @override Widget build(BuildContext context) { + final formattedTitle = StringFormatter.tryFormat(session.name); return TextButton( onPressed: () { showDialog(context: context, builder: (context) => SessionPopup(session: session)); @@ -128,7 +132,7 @@ class SessionTile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(session.name), + formattedTitle == null ? Text(session.name) : RichText(text: formattedTitle), Text("${session.sessionUsers.length}/${session.maxUsers} active users") ], ), diff --git a/lib/widgets/sessions/session_tile.dart b/lib/widgets/sessions/session_tile.dart new file mode 100644 index 0000000..cdf8c65 --- /dev/null +++ b/lib/widgets/sessions/session_tile.dart @@ -0,0 +1,60 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/session.dart'; +import 'package:contacts_plus_plus/string_formatter.dart'; +import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; +import 'package:flutter/material.dart'; + +class LargeSessionTile extends StatelessWidget { + const LargeSessionTile({required this.session, super.key}); + + final Session session; + + @override + Widget build(BuildContext context) { + final formattedName = StringFormatter.tryFormat(session.name, baseStyle: const TextStyle(color: Colors.white)); + return InkWell( + onTap: (){ + showDialog(context: context, builder: (context) => SessionPopup(session: session)); + }, + child: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: CachedNetworkImageProvider( + Aux.neosDbToHttp(session.thumbnail), + ), + fit: BoxFit.cover, + ) + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4), + color: Theme.of(context).colorScheme.background.withAlpha(200), + child: formattedName != null ? RichText(text: formattedName, maxLines: 4, overflow: TextOverflow.ellipsis) + : Text(session.name.overflow, maxLines: 4, overflow: TextOverflow.ellipsis,), + ), + ), + ], + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 4), + color: Theme.of(context).colorScheme.background.withAlpha(200), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("${session.sessionUsers.length}/${session.maxUsers}"), + ], + ), + ) + ], + ), + ), + ); + } + +} \ No newline at end of file diff --git a/lib/widgets/sessions/sessions_list.dart b/lib/widgets/sessions/sessions_list.dart new file mode 100644 index 0000000..fc35ce7 --- /dev/null +++ b/lib/widgets/sessions/sessions_list.dart @@ -0,0 +1,113 @@ + +import 'dart:async'; + +import 'package:contacts_plus_plus/apis/session_api.dart'; +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/client_holder.dart'; +import 'package:contacts_plus_plus/clients/api_client.dart'; +import 'package:contacts_plus_plus/models/session.dart'; +import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; +import 'package:contacts_plus_plus/widgets/friends/expanding_input_fab.dart'; +import 'package:contacts_plus_plus/widgets/sessions/session_tile.dart'; +import 'package:flutter/material.dart'; + +class SessionsList extends StatefulWidget { + const SessionsList({super.key}); + + @override + State createState() => _SessionsListState(); + +} + +class _SessionsListState extends State with AutomaticKeepAliveClientMixin { + Timer? _refreshDelay; + Future>? _sessionsFuture; + String _searchFilter = ""; + + ClientHolder? _clientHolder; + + @override + void didChangeDependencies() async { + super.didChangeDependencies(); + final clientHolder = ClientHolder.of(context); + if (_clientHolder != clientHolder) { + _clientHolder = clientHolder; + final apiClient = _clientHolder!.apiClient; + _refreshSessions(apiClient); + } + } + + void _refreshSessions(ApiClient client) { + if (_refreshDelay?.isActive ?? false) return; + _sessionsFuture = SessionApi.getSessions(client); + _refreshDelay = Timer(const Duration(seconds: 30), (){}); + } + + List _filterSessions(List sessions, {String text=""}) { + return sessions.where((element) => element.name.looseMatch(text)).toList(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Stack( + alignment: Alignment.topCenter, + children: [ + RefreshIndicator( + onRefresh: () async { + _refreshSessions(ClientHolder + .of(context) + .apiClient); + await _sessionsFuture; // Keep showing indicator until done; + }, + child: FutureBuilder( + future: _sessionsFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final sessions = _filterSessions(snapshot.data as List, text: _searchFilter); + return GridView.builder( + itemCount: sessions.length, + itemBuilder: (context, index) { + return LargeSessionTile(session: sessions[index]); + }, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(maxCrossAxisExtent: 256), + ); + } else if (snapshot.hasError) { + return DefaultErrorWidget( + title: "Failed to load sessions", + message: snapshot.error.toString(), + onRetry: () => + _refreshSessions(ClientHolder + .of(context) + .apiClient), + ); + } else { + return const LinearProgressIndicator(); + } + }, + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: ExpandingInputFab( + onInputChanged: (String text) { + setState(() { + _searchFilter = text; + }); + }, + onExpansionChanged: (expanded) { + if (!expanded) { + setState(() { + _searchFilter = ""; + }); + } + }, + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index ad92509..5a6b6b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.13" + bbob_dart: + dependency: transitive + description: + name: bbob_dart + sha256: d754e0dfd800582a6f0a43ae4f12db8eb763e89f584674c334a36e0faaddb1f9 + url: "https://pub.dev" + source: hosted + version: "0.2.1" boolean_selector: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + color: + dependency: "direct main" + description: + name: color + sha256: ddcdf1b3badd7008233f5acffaf20ca9f5dc2cd0172b75f68f24526a5f5725cb + url: "https://pub.dev" + source: hosted + version: "3.0.0" crypto: dependency: "direct main" description: @@ -150,6 +166,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_bbcode: + dependency: "direct main" + description: + name: flutter_bbcode + sha256: "024cb7d3b32d8f7dd155251d09bb92f496aaccb19cfe7313caea2e85d491ac7f" + url: "https://pub.dev" + source: hosted + version: "1.4.0" flutter_blurhash: dependency: transitive description: @@ -280,6 +304,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + hive: + dependency: "direct main" + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" html: dependency: "direct main" description: @@ -433,13 +465,13 @@ packages: source: hosted version: "1.8.2" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider - sha256: c7edf82217d4b2952b2129a61d3ad60f1075b9299e629e149a8d2e39c2e6aad4 + sha256: "3087813781ab814e4157b172f1a11c46be20179fcc9bea043e0fba36bc0acaa2" url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.15" path_provider_android: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 206cbc8..937e3ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.1+1 +version: 1.2.0+1 environment: sdk: '>=2.19.6 <3.0.0' @@ -56,6 +56,10 @@ dependencies: photo_view: ^0.14.0 file_picker: ^5.2.11 crypto: ^3.0.3 + flutter_bbcode: ^1.4.0 + color: ^3.0.0 + path_provider: ^2.0.15 + hive: ^2.2.3 dev_dependencies: flutter_test: