improved websocket connection state info
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-04-09 22:51:36 +02:00
parent 587740f306
commit 527bf51bff
11 changed files with 283 additions and 222 deletions

View file

@ -31,7 +31,7 @@ import 'package:twonly/src/views/camera/camera_preview_components/zoom_selector.
import 'package:twonly/src/views/camera/share_image_editor.view.dart'; import 'package:twonly/src/views/camera/share_image_editor.view.dart';
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart'; import 'package:twonly/src/views/camera/share_image_editor/action_button.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/loader.dart'; import 'package:twonly/src/views/components/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:twonly/src/views/home.view.dart'; import 'package:twonly/src/views/home.view.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';

View file

@ -6,7 +6,7 @@ import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/c
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/cards/youtube.card.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/cards/youtube.card.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader/three_rotating_dots.loader.dart';
class LinkPreviewLayer extends StatefulWidget { class LinkPreviewLayer extends StatefulWidget {
const LinkPreviewLayer({ const LinkPreviewLayer({
@ -32,8 +32,9 @@ class _LinkPreviewLayerState extends State<LinkPreviewLayer> {
Future<void> initAsync() async { Future<void> initAsync() async {
if (widget.layerData.metadata == null) { if (widget.layerData.metadata == null) {
widget.layerData.metadata = widget.layerData.metadata = await getMetadata(
await getMetadata(widget.layerData.link.toString()); widget.layerData.link.toString(),
);
if (widget.layerData.metadata == null) { if (widget.layerData.metadata == null) {
widget.layerData.error = true; widget.layerData.error = true;
} }

View file

@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader/three_rotating_dots.loader.dart';
class MastodonPostCard extends StatelessWidget { class MastodonPostCard extends StatelessWidget {
const MastodonPostCard({required this.info, super.key}); const MastodonPostCard({required this.info, super.key});

View file

@ -9,15 +9,14 @@ import 'package:provider/provider.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/chats/chat_list_components/connection_info.comp.dart';
import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart'; import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart';
import 'package:twonly/src/views/chats/chat_list_components/group_list_item.dart'; import 'package:twonly/src/views/chats/chat_list_components/group_list_item.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/connection_status_badge.dart';
import 'package:twonly/src/views/components/notification_badge.dart'; import 'package:twonly/src/views/components/notification_badge.dart';
class ChatListView extends StatefulWidget { class ChatListView extends StatefulWidget {
@ -45,8 +44,9 @@ class _ChatListViewState extends State<ChatListView> {
final stream = twonlyDB.groupsDao.watchGroupsForChatList(); final stream = twonlyDB.groupsDao.watchGroupsForChatList();
_contactsSub = stream.listen((groups) { _contactsSub = stream.listen((groups) {
setState(() { setState(() {
_groupsNotPinned = _groupsNotPinned = groups
groups.where((x) => !x.pinned && !x.archived).toList(); .where((x) => !x.pinned && !x.archived)
.toList();
_groupsPinned = groups.where((x) => x.pinned && !x.archived).toList(); _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList();
_groupsArchived = groups.where((x) => x.archived).toList(); _groupsArchived = groups.where((x) => x.archived).toList();
}); });
@ -64,8 +64,10 @@ class _ChatListViewState extends State<ChatListView> {
} }
final changeLog = await rootBundle.loadString('CHANGELOG.md'); final changeLog = await rootBundle.loadString('CHANGELOG.md');
final changeLogHash = final changeLogHash = (await compute(
(await compute(Sha256().hash, changeLog.codeUnits)).bytes; Sha256().hash,
changeLog.codeUnits,
)).bytes;
if (!gUser.hideChangeLog && if (!gUser.hideChangeLog &&
gUser.lastChangeLogHash.toString() != changeLogHash.toString()) { gUser.lastChangeLogHash.toString() != changeLogHash.toString()) {
await updateUserdata((u) { await updateUserdata((u) {
@ -93,22 +95,23 @@ class _ChatListViewState extends State<ChatListView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isConnected = context.watch<CustomChangeProvider>().isConnected;
final plan = context.watch<PurchasesProvider>().plan; final plan = context.watch<PurchasesProvider>().plan;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: Row(
children: [ children: [
GestureDetector( ConnectionStatusBadge(
onTap: () async { child: GestureDetector(
await context.push(Routes.settingsProfile); onTap: () async {
if (!mounted) return; await context.push(Routes.settingsProfile);
setState(() {}); // gUser has updated if (!mounted) return;
}, setState(() {}); // gUser has updated
child: AvatarIcon( },
myAvatar: true, child: AvatarIcon(
fontSize: 14, myAvatar: true,
color: context.color.onSurface.withAlpha(20), fontSize: 14,
color: context.color.onSurface.withAlpha(20),
),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -121,8 +124,10 @@ class _ChatListViewState extends State<ChatListView> {
color: context.color.primary, color: context.color.primary,
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
), ),
padding: padding: const EdgeInsets.symmetric(
const EdgeInsets.symmetric(horizontal: 5, vertical: 3), horizontal: 5,
vertical: 3,
),
child: Text( child: Text(
plan.name, plan.name,
style: TextStyle( style: TextStyle(
@ -163,87 +168,77 @@ class _ChatListViewState extends State<ChatListView> {
), ),
], ],
), ),
body: Stack( body: RefreshIndicator(
children: [ onRefresh: () async {
Positioned( await apiService.close(() {});
top: 0, await apiService.connect();
left: 0, await Future.delayed(const Duration(seconds: 1));
right: 0, },
child: isConnected ? Container() : const ConnectionInfo(), child:
), (_groupsNotPinned.isEmpty &&
Positioned.fill( _groupsPinned.isEmpty &&
child: RefreshIndicator( _groupsArchived.isEmpty)
onRefresh: () async { ? Center(
await apiService.close(() {}); child: Padding(
await apiService.connect(); padding: const EdgeInsets.all(10),
await Future.delayed(const Duration(seconds: 1)); child: OutlinedButton.icon(
}, icon: const Icon(Icons.person_add),
child: (_groupsNotPinned.isEmpty && onPressed: () => context.push(Routes.chatsAddNewUser),
_groupsPinned.isEmpty && label: Text(
_groupsArchived.isEmpty) context.lang.chatListViewSearchUserNameBtn,
? Center(
child: Padding(
padding: const EdgeInsets.all(10),
child: OutlinedButton.icon(
icon: const Icon(Icons.person_add),
onPressed: () => context.push(Routes.chatsAddNewUser),
label: Text(
context.lang.chatListViewSearchUserNameBtn,
),
),
),
)
: ListView.builder(
itemCount: _groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0) +
_groupsNotPinned.length +
(_groupsArchived.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >=
_groupsNotPinned.length +
_groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0)) {
if (_groupsArchived.isEmpty) return Container();
return ListTile(
title: Text(
'${context.lang.archivedChats} (${_groupsArchived.length})',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
onTap: () => context.push(Routes.chatsArchived),
);
}
// Check if the index is for the pinned users
if (index < _groupsPinned.length) {
final group = _groupsPinned[index];
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
);
}
// If there are pinned users, account for the Divider
var adjustedIndex = index - _groupsPinned.length;
if (_groupsPinned.isNotEmpty && adjustedIndex == 0) {
return const Divider();
}
// Adjust the index for the contacts list
adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0);
// Get the contacts that are not pinned
final group = _groupsNotPinned.elementAt(
adjustedIndex,
);
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
);
},
), ),
), ),
), ),
], )
: ListView.builder(
itemCount:
_groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0) +
_groupsNotPinned.length +
(_groupsArchived.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index >=
_groupsNotPinned.length +
_groupsPinned.length +
(_groupsPinned.isNotEmpty ? 1 : 0)) {
if (_groupsArchived.isEmpty) return Container();
return ListTile(
title: Text(
'${context.lang.archivedChats} (${_groupsArchived.length})',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13),
),
onTap: () => context.push(Routes.chatsArchived),
);
}
// Check if the index is for the pinned users
if (index < _groupsPinned.length) {
final group = _groupsPinned[index];
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
);
}
// If there are pinned users, account for the Divider
var adjustedIndex = index - _groupsPinned.length;
if (_groupsPinned.isNotEmpty && adjustedIndex == 0) {
return const Divider();
}
// Adjust the index for the contacts list
adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0);
// Get the contacts that are not pinned
final group = _groupsNotPinned.elementAt(
adjustedIndex,
);
return GroupListItem(
key: ValueKey(group.groupId),
group: group,
);
},
),
), ),
floatingActionButton: Padding( floatingActionButton: Padding(
padding: const EdgeInsets.only(bottom: 30), padding: const EdgeInsets.only(bottom: 30),

View file

@ -1,98 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
class ConnectionInfo extends StatefulWidget {
const ConnectionInfo({super.key});
@override
State<ConnectionInfo> createState() => _ConnectionInfoWidgetState();
}
class _ConnectionInfoWidgetState extends State<ConnectionInfo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _positionAnim;
late Animation<double> _widthAnim;
bool showAnimation = false;
final double minBarWidth = 40;
final double maxBarWidth = 150;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
);
_positionAnim = Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_widthAnim = TweenSequence([
TweenSequenceItem(
tween: Tween<double>(begin: minBarWidth, end: maxBarWidth),
weight: 50,
),
TweenSequenceItem(
tween: Tween<double>(begin: maxBarWidth, end: minBarWidth),
weight: 50,
),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
// Delay start by 2 seconds
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
unawaited(_controller.repeat(reverse: true));
setState(() {
showAnimation = true;
});
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!showAnimation) return Container();
final screenWidth = MediaQuery.of(context).size.width;
return SizedBox(
width: screenWidth,
height: 1,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
final barWidth = _widthAnim.value;
final left = _positionAnim.value * (screenWidth - barWidth);
return Stack(
children: [
Positioned(
left: left,
top: 0,
bottom: 0,
child: Container(
width: barWidth,
decoration: BoxDecoration(
color: context.color.primary,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
},
),
);
}
}

View file

@ -27,7 +27,7 @@ import 'package:twonly/src/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/views/chats/media_viewer_components/additional_message_content.dart'; import 'package:twonly/src/views/chats/media_viewer_components/additional_message_content.dart';
import 'package:twonly/src/views/chats/media_viewer_components/reaction_buttons.component.dart'; import 'package:twonly/src/views/chats/media_viewer_components/reaction_buttons.component.dart';
import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/animate_icon.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/loader/ripple.loader.dart';
class ConnectionStatusBadge extends StatelessWidget {
const ConnectionStatusBadge({
required this.child,
super.key,
});
final Widget child;
@override
Widget build(BuildContext context) {
final isConnected = context.watch<CustomChangeProvider>().isConnected;
return Stack(
children: [
if (!isConnected)
const Positioned.fill(
child: SpinKitRipple(
color: Colors.red,
),
),
Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: isConnected
? context.color.primary.withAlpha(100)
: Colors.red,
),
),
padding: const EdgeInsets.all(0.5),
child: Center(
child: child,
),
),
],
);
}
}

View file

@ -0,0 +1,114 @@
// FROM: https://github.com/jogboms/flutter_spinkit/blob/master/lib/src/ripple.dart
// ignore_for_file: prefer_int_literals
import 'package:flutter/material.dart';
class SpinKitRipple extends StatefulWidget {
const SpinKitRipple({
super.key,
this.color,
this.size = 50.0,
this.borderWidth = 6.0,
this.itemBuilder,
this.duration = const Duration(milliseconds: 1800),
this.controller,
}) : assert(
!(itemBuilder is IndexedWidgetBuilder && color is Color) &&
!(itemBuilder == null && color == null),
'You should specify either a itemBuilder or a color',
);
final Color? color;
final double size;
final double borderWidth;
final IndexedWidgetBuilder? itemBuilder;
final Duration duration;
final AnimationController? controller;
@override
State<SpinKitRipple> createState() => _SpinKitRippleState();
}
class _SpinKitRippleState extends State<SpinKitRipple>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation1;
late Animation<double> _animation2;
@override
void initState() {
super.initState();
_controller =
(widget.controller ??
AnimationController(vsync: this, duration: widget.duration))
..addListener(() {
if (mounted) {
setState(() {});
}
})
..repeat();
_animation1 = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.75),
),
);
_animation2 = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.25, 1),
),
);
}
@override
void dispose() {
if (widget.controller == null) {
_controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
children: <Widget>[
Opacity(
opacity: 1.0 - _animation1.value,
child: Transform.scale(
scale: _animation1.value,
child: _itemBuilder(0),
),
),
Opacity(
opacity: 1.0 - _animation2.value,
child: Transform.scale(
scale: _animation2.value,
child: _itemBuilder(1),
),
),
],
),
);
}
Widget _itemBuilder(int index) {
return SizedBox.fromSize(
size: Size.square(widget.size),
child: widget.itemBuilder != null
? widget.itemBuilder!(context, index)
: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: widget.color!,
width: widget.borderWidth,
),
),
),
);
}
}

View file

@ -10,7 +10,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/views/memories/memories_item_thumbnail.dart'; import 'package:twonly/src/views/memories/memories_item_thumbnail.dart';
import 'package:twonly/src/views/memories/memories_photo_slider.view.dart'; import 'package:twonly/src/views/memories/memories_photo_slider.view.dart';
@ -43,8 +43,8 @@ class MemoriesViewState extends State<MemoriesView> {
} }
Future<void> initAsync() async { Future<void> initAsync() async {
final nonHashedFiles = final nonHashedFiles = await twonlyDB.mediaFilesDao
await twonlyDB.mediaFilesDao.getAllNonHashedStoredMediaFiles(); .getAllNonHashedStoredMediaFiles();
if (nonHashedFiles.isNotEmpty) { if (nonHashedFiles.isNotEmpty) {
setState(() { setState(() {
_filesToMigrate = nonHashedFiles.length; _filesToMigrate = nonHashedFiles.length;
@ -100,8 +100,9 @@ class MemoriesViewState extends State<MemoriesView> {
), ),
); );
for (var i = 0; i < galleryItems.length; i++) { for (var i = 0; i < galleryItems.length; i++) {
final month = DateFormat('MMMM yyyy') final month = DateFormat(
.format(galleryItems[i].mediaService.mediaFile.createdAt); 'MMMM yyyy',
).format(galleryItems[i].mediaService.mediaFile.createdAt);
if (lastMonth != month) { if (lastMonth != month) {
lastMonth = month; lastMonth = month;
months.add(month); months.add(month);
@ -259,15 +260,16 @@ class MemoriesViewState extends State<MemoriesView> {
int index, int index,
) async { ) async {
await Navigator.push( await Navigator.push(
context, context,
PageRouteBuilder( PageRouteBuilder(
opaque: false, opaque: false,
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView( pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView(
galleryItems: galleryItems, galleryItems: galleryItems,
initialIndex: index, initialIndex: index,
), ),
), ),
) as bool?; )
as bool?;
if (mounted) setState(() {}); if (mounted) setState(() {});
} }
} }

View file

@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader/three_rotating_dots.loader.dart';
class DiagnosticsView extends StatefulWidget { class DiagnosticsView extends StatefulWidget {
const DiagnosticsView({super.key}); const DiagnosticsView({super.key});
@ -98,8 +98,11 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_entries = _entries = widget.logLines
widget.logLines.split('\n').reversed.map(_LogEntry.parse).toList(); .split('\n')
.reversed
.map(_LogEntry.parse)
.toList();
} }
void _setFilter(String level) => setState(() => _filterLevel = level); void _setFilter(String level) => setState(() => _filterLevel = level);
@ -187,8 +190,9 @@ class _LogViewerWidgetState extends State<LogViewerWidget> {
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
tooltip: tooltip: _showTimestamps
_showTimestamps ? 'Hide timestamps' : 'Show timestamps', ? 'Hide timestamps'
: 'Show timestamps',
onPressed: _toggleTimestamps, onPressed: _toggleTimestamps,
icon: Icon( icon: Icon(
_showTimestamps _showTimestamps