Merge pull request #398 from twonlyapp/dev
Some checks are pending
Publish on Github / build_and_publish (push) Waiting to run

- Improved: Show input indicator in the chat overview as well 
- Improved: Username change error handling
- Fix: Phantom push notification 
- Fix: Start in chat, if configured
- Fix: Smaller UI fixes
This commit is contained in:
Tobi 2026-04-20 13:10:12 +02:00 committed by GitHub
commit e52c40d824
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 325 additions and 136 deletions

View file

@ -1,5 +1,13 @@
# Changelog # Changelog
## 0.1.6
- Improved: Show input indicator in the chat overview as well
- Improved: Username change error handling
- Fix: Phantom push notification
- Fix: Start in chat, if configured
- Fix: Smaller UI fixes
## 0.1.5 ## 0.1.5
- Fix: Reupload of media files was not working properly - Fix: Reupload of media files was not working properly

View file

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:sentry_flutter/sentry_flutter.dart';
@ -33,9 +35,20 @@ void main() async {
globalApplicationSupportDirectory = globalApplicationSupportDirectory =
(await getApplicationSupportDirectory()).path; (await getApplicationSupportDirectory()).path;
initLogger();
await initFCMService(); await initFCMService();
final user = await getUser(); var user = await getUser();
if (Platform.isIOS && user != null) {
final db = File('$globalApplicationSupportDirectory/twonly.sqlite');
if (!db.existsSync()) {
Log.error('[twonly] IOS: App was removed and then reinstalled again...');
await const FlutterSecureStorage().deleteAll();
user = await getUser();
}
}
if (user != null) { if (user != null) {
gUser = user; gUser = user;
@ -57,8 +70,6 @@ void main() async {
await deleteLocalUserData(); await deleteLocalUserData();
} }
initLogger();
final settingsController = SettingsChangeProvider(); final settingsController = SettingsChangeProvider();
await settingsController.loadSettings(); await settingsController.loadSettings();

View file

@ -24,13 +24,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
); );
} }
final rowId = await into( await into(mediaFiles).insertOnConflictUpdate(insertMediaFile);
mediaFiles,
).insertOnConflictUpdate(insertMediaFile); final mediaId = insertMediaFile.mediaId.value;
return await (select( return await (select(
mediaFiles, mediaFiles,
)..where((t) => t.rowId.equals(rowId))).getSingle(); )..where((t) => t.mediaId.equals(mediaId))).getSingle();
} catch (e) { } catch (e) {
Log.error('Could not insert media file: $e'); Log.error('Could not insert media file: $e');
return null; return null;

View file

@ -64,18 +64,41 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
return query.map((row) => row.readTable(messages)).watch(); return query.map((row) => row.readTable(messages)).watch();
} }
Stream<Message?> watchLastMessage(String groupId) { Future<Stream<Message?>> watchLastMessage(String groupId) async {
final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract(
Duration(
milliseconds: group!.deleteMessagesAfterMilliseconds,
),
);
return (select(messages) return (select(messages)
..where((t) => t.groupId.equals(groupId)) ..where(
(t) =>
t.groupId.equals(groupId) &
// messages in groups will only be removed in case all members have received it...
// so ensuring that this message is not shown in the messages anymore
(t.openedAt.isBiggerThanValue(deletionTime) |
t.openedAt.isNull()),
)
..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) ..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
..limit(1)) ..limit(1))
.watchSingleOrNull(); .watchSingleOrNull();
} }
Stream<List<Message>> watchByGroupId(String groupId) { Future<Stream<List<Message>>> watchByGroupId(String groupId) async {
final group = await twonlyDB.groupsDao.getGroup(groupId);
final deletionTime = clock.now().subtract(
Duration(
milliseconds: group!.deleteMessagesAfterMilliseconds,
),
);
return ((select(messages)..where( return ((select(messages)..where(
(t) => (t) =>
t.groupId.equals(groupId) & t.groupId.equals(groupId) &
// messages in groups will only be removed in case all members have received it...
// so ensuring that this message is not shown in the messages anymore
(t.openedAt.isBiggerThanValue(deletionTime) |
t.openedAt.isNull()) &
(t.isDeletedFromSender.equals(true) | (t.isDeletedFromSender.equals(true) |
(t.type.equals(MessageType.text.name).not() | (t.type.equals(MessageType.text.name).not() |
t.type.equals(MessageType.media.name).not()) | t.type.equals(MessageType.media.name).not()) |
@ -127,7 +150,8 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
(m.mediaStored.equals(true) & (m.mediaStored.equals(true) &
m.isDeletedFromSender.equals(true) | m.isDeletedFromSender.equals(true) |
m.mediaStored.equals(false)) & m.mediaStored.equals(false)) &
(m.openedAt.isSmallerThanValue(deletionTime) | // Only remove the message when ALL members have seen it. Otherwise the receipt will also be deleted which could cause issues in case a member opens the image later..
(m.openedByAll.isSmallerThanValue(deletionTime) |
(m.isDeletedFromSender.equals(true) & (m.isDeletedFromSender.equals(true) &
m.createdAt.isSmallerThanValue(deletionTime))), m.createdAt.isSmallerThanValue(deletionTime))),
)) ))

View file

@ -70,11 +70,11 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
}; };
final response = await iapConnection.queryProductDetails(ids); final response = await iapConnection.queryProductDetails(ids);
if (response.notFoundIDs.isNotEmpty) { if (response.notFoundIDs.isNotEmpty) {
Log.error(response.notFoundIDs); Log.warn(response.notFoundIDs);
} }
products = response.productDetails.map(PurchasableProduct.new).toList(); products = response.productDetails.map(PurchasableProduct.new).toList();
if (products.isEmpty) { if (products.isEmpty) {
Log.error('Could not load any products from the store!'); Log.warn('Could not load any products from the store!');
} }
storeState = StoreState.available; storeState = StoreState.available;
notifyListeners(); notifyListeners();

View file

@ -117,8 +117,8 @@ Future<void> handleMedia(
} }
} }
late MediaFile? mediaFile; MediaFile? mediaFile;
late Message? message; Message? message;
await twonlyDB.transaction(() async { await twonlyDB.transaction(() async {
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia( mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
@ -163,7 +163,7 @@ Future<void> handleMedia(
); );
}); });
if (message != null) { if (message != null && mediaFile != null) {
await twonlyDB.groupsDao.increaseLastMessageExchange( await twonlyDB.groupsDao.increaseLastMessageExchange(
groupId, groupId,
fromTimestamp(media.timestamp), fromTimestamp(media.timestamp),
@ -176,6 +176,10 @@ Future<void> handleMedia(
); );
unawaited(startDownloadMedia(mediaFile!, false)); unawaited(startDownloadMedia(mediaFile!, false));
} else {
Log.error(
'Could not insert new message as both the message and mediaFile are empty.',
);
} }
} }

View file

@ -102,6 +102,12 @@ Future<void> reuploadMediaFiles() async {
.getMessageById(messageId) .getMessageById(messageId)
.getSingleOrNull(); .getSingleOrNull();
if (message == null || message.mediaId == null) { if (message == null || message.mediaId == null) {
// The message or media file does not exists any more, so delete the receipt...
if (message != null) {
// The media file of the message does not exist anymore. Removing it...
await twonlyDB.messagesDao.deleteMessagesById(messageId);
}
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
Log.error( Log.error(
'Message not found for reupload of the receipt (${message == null} - ${message?.mediaId}).', 'Message not found for reupload of the receipt (${message == null} - ${message?.mediaId}).',
); );

View file

@ -102,21 +102,23 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
message.encryptedContent, message.encryptedContent,
); );
final pushNotification = await getPushNotificationFromEncryptedContent( Log.info('Uploading $receiptId.');
receipt.contactId,
receipt.messageId,
encryptedContent,
);
Log.info('Uploading $receiptId. (${pushNotification?.kind})');
Uint8List? pushData; Uint8List? pushData;
if (pushNotification != null && receipt.retryCount <= 1) { if (receipt.retryCount == 0) {
// Only show the push notification the first two time. final pushNotification = await getPushNotificationFromEncryptedContent(
pushData = await encryptPushNotification(
receipt.contactId, receipt.contactId,
pushNotification, receipt.messageId,
encryptedContent,
); );
if (pushNotification != null) {
// Only show the push notification the first two time.
pushData = await encryptPushNotification(
receipt.contactId,
pushNotification,
);
}
} }
if (message.type == pb.Message_Type.TEST_NOTIFICATION) { if (message.type == pb.Message_Type.TEST_NOTIFICATION) {

View file

@ -214,6 +214,8 @@ Future<void> showLocalPushNotification(
pushNotification.kind == PushKind.reactionToText || pushNotification.kind == PushKind.reactionToText ||
pushNotification.kind == PushKind.reactionToAudio)) { pushNotification.kind == PushKind.reactionToAudio)) {
payload = Routes.chatsMessages(groupId); payload = Routes.chatsMessages(groupId);
} else {
payload = Routes.chats;
} }
await flutterLocalNotificationsPlugin.show( await flutterLocalNotificationsPlugin.show(

View file

@ -13,6 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart'; import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart'; import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart';
import 'package:twonly/src/views/chats/chat_list_components/typing_indicator_subtitle.dart';
import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart'; import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/flame.dart';
@ -63,9 +64,10 @@ class _UserListItem extends State<GroupListItem> {
} }
Future<void> initStreams() async { Future<void> initStreams() async {
_lastMessageStream = twonlyDB.messagesDao _lastMessageStream =
.watchLastMessage(widget.group.groupId) (await twonlyDB.messagesDao.watchLastMessage(
.listen((update) { widget.group.groupId,
)).listen((update) {
protectUpdateState.protect(() async { protectUpdateState.protect(() async {
await updateState(update, _messagesNotOpened); await updateState(update, _messagesNotOpened);
}); });
@ -227,6 +229,7 @@ class _UserListItem extends State<GroupListItem> {
VerifiedShield( VerifiedShield(
group: widget.group, group: widget.group,
showOnlyIfVerified: true, showOnlyIfVerified: true,
clickable: false,
size: 12, size: 12,
), ),
], ],
@ -249,6 +252,9 @@ class _UserListItem extends State<GroupListItem> {
) )
: Row( : Row(
children: [ children: [
TypingIndicatorSubtitle(
groupId: widget.group.groupId,
),
MessageSendStateIcon( MessageSendStateIcon(
_previewMessages, _previewMessages,
_previewMediaFiles, _previewMediaFiles,

View file

@ -0,0 +1,77 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/chats/chat_messages_components/typing_indicator.dart';
class TypingIndicatorSubtitle extends StatefulWidget {
const TypingIndicatorSubtitle({required this.groupId, super.key});
final String groupId;
@override
State<TypingIndicatorSubtitle> createState() =>
_TypingIndicatorSubtitleState();
}
class _TypingIndicatorSubtitleState extends State<TypingIndicatorSubtitle> {
List<GroupMember> _groupMembers = [];
late StreamSubscription<List<(Contact, GroupMember)>> membersSub;
late Timer _periodicUpdate;
@override
void initState() {
super.initState();
_periodicUpdate = Timer.periodic(const Duration(seconds: 1), (_) {
filterOpenUsers(_groupMembers);
});
final membersStream = twonlyDB.groupsDao.watchGroupMembers(
widget.groupId,
);
membersSub = membersStream.listen((update) {
filterOpenUsers(update.map((m) => m.$2).toList());
});
}
void filterOpenUsers(List<GroupMember> input) {
if (mounted) {
setState(() {
_groupMembers = input.where(isTyping).toList();
});
}
}
@override
void dispose() {
membersSub.cancel();
_periodicUpdate.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container();
return Padding(
padding: const EdgeInsets.only(right: 5),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: getMessageColor(true),
borderRadius: BorderRadius.circular(12),
),
child: Transform.scale(
scale: 0.6,
child: const AnimatedTypingDots(
isTyping: true,
),
),
),
);
}
}

View file

@ -107,7 +107,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
}); });
}); });
final msgStream = twonlyDB.messagesDao.watchByGroupId(widget.groupId); final msgStream = await twonlyDB.messagesDao.watchByGroupId(widget.groupId);
messageSub = msgStream.listen((update) async { messageSub = msgStream.listen((update) async {
allMessages = update; allMessages = update;
await protectMessageUpdating.protect(() async { await protectMessageUpdating.protect(() async {

View file

@ -9,6 +9,28 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart';
bool isTyping(GroupMember member) {
return member.lastTypeIndicator != null &&
clock
.now()
.difference(
member.lastTypeIndicator!,
)
.inSeconds <=
2;
}
bool hasChatOpen(GroupMember member) {
return member.lastChatOpened != null &&
clock
.now()
.difference(
member.lastChatOpened!,
)
.inSeconds <=
6;
}
class TypingIndicator extends StatefulWidget { class TypingIndicator extends StatefulWidget {
const TypingIndicator({required this.group, super.key}); const TypingIndicator({required this.group, super.key});
@ -18,10 +40,8 @@ class TypingIndicator extends StatefulWidget {
State<TypingIndicator> createState() => _TypingIndicatorState(); State<TypingIndicator> createState() => _TypingIndicatorState();
} }
class _TypingIndicatorState extends State<TypingIndicator> class _TypingIndicatorState extends State<TypingIndicator> {
with SingleTickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late List<Animation<double>> _animations;
List<GroupMember> _groupMembers = []; List<GroupMember> _groupMembers = [];
@ -43,7 +63,90 @@ class _TypingIndicatorState extends State<TypingIndicator>
membersSub = membersStream.listen((update) { membersSub = membersStream.listen((update) {
filterOpenUsers(update.map((m) => m.$2).toList()); filterOpenUsers(update.map((m) => m.$2).toList());
}); });
}
void filterOpenUsers(List<GroupMember> input) {
if (mounted) {
setState(() {
_groupMembers = input.where(hasChatOpen).toList();
});
}
}
@override
void dispose() {
_controller.dispose();
membersSub.cancel();
_periodicUpdate.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container();
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: _groupMembers
.map(
(member) => Padding(
key: Key('typing_indicator_${member.contactId}'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.group.isDirectChat)
GestureDetector(
onTap: () => context.push(
Routes.profileContact(member.contactId),
),
child: AvatarIcon(
contactId: member.contactId,
fontSize: 12,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: getMessageColor(true),
borderRadius: BorderRadius.circular(12),
),
child: AnimatedTypingDots(
isTyping: isTyping(member),
),
),
Expanded(child: Container()),
],
),
),
)
.toList(),
),
),
);
}
}
class AnimatedTypingDots extends StatefulWidget {
const AnimatedTypingDots({required this.isTyping, super.key});
final bool isTyping;
@override
State<AnimatedTypingDots> createState() => _AnimatedTypingDotsState();
}
class _AnimatedTypingDotsState extends State<AnimatedTypingDots>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<Animation<double>> _animations;
@override
void initState() {
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 1000),
vsync: this, vsync: this,
@ -77,93 +180,18 @@ class _TypingIndicatorState extends State<TypingIndicator>
), ),
); );
}); });
} super.initState();
void filterOpenUsers(List<GroupMember> input) {
setState(() {
_groupMembers = input.where(hasChatOpen).toList();
});
}
@override
void dispose() {
_controller.dispose();
membersSub.cancel();
_periodicUpdate.cancel();
super.dispose();
}
bool isTyping(GroupMember member) {
return member.lastTypeIndicator != null &&
clock
.now()
.difference(
member.lastTypeIndicator!,
)
.inSeconds <=
2;
}
bool hasChatOpen(GroupMember member) {
return member.lastChatOpened != null &&
clock
.now()
.difference(
member.lastChatOpened!,
)
.inSeconds <=
6;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container(); return Row(
mainAxisSize: MainAxisSize.min,
return Align( children: List.generate(
alignment: Alignment.centerLeft, 3,
child: Padding( (index) => _AnimatedDot(
padding: const EdgeInsets.all(12), isTyping: widget.isTyping,
child: Column( animation: _animations[index],
children: _groupMembers
.map(
(member) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.group.isDirectChat)
GestureDetector(
onTap: () => context.push(
Routes.profileContact(member.contactId),
),
child: AvatarIcon(
contactId: member.contactId,
fontSize: 12,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: getMessageColor(true),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
3,
(index) => _AnimatedDot(
isTyping: isTyping(member),
animation: _animations[index],
),
),
),
),
Expanded(child: Container()),
],
),
),
)
.toList(),
), ),
), ),
); );

View file

@ -13,12 +13,14 @@ class VerifiedShield extends StatefulWidget {
super.key, super.key,
this.size = 15, this.size = 15,
this.showOnlyIfVerified = false, this.showOnlyIfVerified = false,
this.clickable = true,
}); });
final Group? group; final Group? group;
final Contact? contact; final Contact? contact;
final double size; final double size;
final bool showOnlyIfVerified; final bool showOnlyIfVerified;
final bool clickable;
@override @override
State<VerifiedShield> createState() => _VerifiedShieldState(); State<VerifiedShield> createState() => _VerifiedShieldState();
@ -61,7 +63,7 @@ class _VerifiedShieldState extends State<VerifiedShield> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!isVerified && widget.showOnlyIfVerified) return Container(); if (!isVerified && widget.showOnlyIfVerified) return Container();
return GestureDetector( return GestureDetector(
onTap: (contact == null) onTap: (contact == null || !widget.clickable)
? null ? null
: () => context.push(Routes.settingsHelpFaqVerifyBadge), : () => context.push(Routes.settingsHelpFaqVerifyBadge),
child: ColoredBox( child: ColoredBox(

View file

@ -49,11 +49,11 @@ class Shade extends StatelessWidget {
} }
class HomeViewState extends State<HomeView> { class HomeViewState extends State<HomeView> {
int activePageIdx = 0; int _activePageIdx = 1;
final MainCameraController _mainCameraController = MainCameraController(); final MainCameraController _mainCameraController = MainCameraController();
final PageController homeViewPageController = PageController(initialPage: 1); final PageController _homeViewPageController = PageController(initialPage: 1);
late StreamSubscription<List<SharedFile>> _intentStreamSub; late StreamSubscription<List<SharedFile>> _intentStreamSub;
late StreamSubscription<Uri> _deepLinkSub; late StreamSubscription<Uri> _deepLinkSub;
@ -67,10 +67,10 @@ class HomeViewState extends State<HomeView> {
bool onPageView(ScrollNotification notification) { bool onPageView(ScrollNotification notification) {
disableCameraTimer?.cancel(); disableCameraTimer?.cancel();
if (notification.depth == 0 && notification is ScrollUpdateNotification) { if (notification.depth == 0 && notification is ScrollUpdateNotification) {
final page = homeViewPageController.page ?? 0; final page = _homeViewPageController.page ?? 0;
lastChange = page; lastChange = page;
setState(() { setState(() {
offsetFromOne = 1.0 - (homeViewPageController.page ?? 0); offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0);
offsetRatio = offsetFromOne.abs(); offsetRatio = offsetFromOne.abs();
}); });
} }
@ -100,17 +100,17 @@ class HomeViewState extends State<HomeView> {
_mainCameraController.setState = () { _mainCameraController.setState = () {
if (mounted) setState(() {}); if (mounted) setState(() {});
}; };
activePageIdx = widget.initialPage;
globalUpdateOfHomeViewPageIndex = (index) { globalUpdateOfHomeViewPageIndex = (index) {
homeViewPageController.jumpToPage(index); _homeViewPageController.jumpToPage(index);
setState(() { setState(() {
activePageIdx = index; _activePageIdx = index;
}); });
}; };
selectNotificationStream.stream.listen((response) async { selectNotificationStream.stream.listen((response) async {
if (response.payload != null && if (response.payload != null &&
response.payload!.startsWith(Routes.chats)) { response.payload!.startsWith(Routes.chats) &&
response.payload! != Routes.chats) {
await routerProvider.push(response.payload!); await routerProvider.push(response.payload!);
} }
globalUpdateOfHomeViewPageIndex(0); globalUpdateOfHomeViewPageIndex(0);
@ -134,6 +134,11 @@ class HomeViewState extends State<HomeView> {
context, context,
_mainCameraController.setSharedLinkForPreview, _mainCameraController.setSharedLinkForPreview,
); );
WidgetsBinding.instance.addPostFrameCallback((_) {
if (widget.initialPage == 0) {
globalUpdateOfHomeViewPageIndex(0);
}
});
} }
@override @override
@ -196,10 +201,10 @@ class HomeViewState extends State<HomeView> {
onNotification: onPageView, onNotification: onPageView,
child: Positioned.fill( child: Positioned.fill(
child: PageView( child: PageView(
controller: homeViewPageController, controller: _homeViewPageController,
onPageChanged: (index) { onPageChanged: (index) {
setState(() { setState(() {
activePageIdx = index; _activePageIdx = index;
}); });
}, },
children: [ children: [
@ -222,7 +227,7 @@ class HomeViewState extends State<HomeView> {
child: CameraPreviewControllerView( child: CameraPreviewControllerView(
mainController: _mainCameraController, mainController: _mainCameraController,
isVisible: isVisible:
((1 - (offsetRatio * 4) % 1) == 1) && activePageIdx == 1, ((1 - (offsetRatio * 4) % 1) == 1) && _activePageIdx == 1,
), ),
), ),
), ),
@ -253,15 +258,15 @@ class HomeViewState extends State<HomeView> {
), ),
], ],
onTap: (index) async { onTap: (index) async {
activePageIdx = index; _activePageIdx = index;
await homeViewPageController.animateToPage( await _homeViewPageController.animateToPage(
index, index,
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
curve: Curves.bounceIn, curve: Curves.bounceIn,
); );
if (mounted) setState(() {}); if (mounted) setState(() {});
}, },
currentIndex: activePageIdx, currentIndex: _activePageIdx,
), ),
); );
} }

View file

@ -57,14 +57,28 @@ class _ProfileViewState extends State<ProfileView> {
} }
Future<void> _updateUsername(String username) async { Future<void> _updateUsername(String username) async {
final result = await apiService.changeUsername(username); var filteredUsername = username.replaceAll(
RegExp('[^a-zA-Z0-9._]'),
'',
);
if (filteredUsername.length > 12) {
filteredUsername = filteredUsername.substring(0, 12);
}
final result = await apiService.changeUsername(filteredUsername);
if (result.isError) { if (result.isError) {
if (!mounted) return; if (!mounted) return;
if (result.error == ErrorCode.UsernameAlreadyTaken) { if (result.error == ErrorCode.UsernameAlreadyTaken ||
result.error == ErrorCode.UsernameNotValid) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.lang.errorUsernameAlreadyTaken), content: Text(
result.error == ErrorCode.UsernameAlreadyTaken
? context.lang.errorUsernameAlreadyTaken
: context.lang.errorUsernameNotValid,
),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.1.5+105 version: 0.1.6+106
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0