fix #101 and show when not connected

This commit is contained in:
otsmr 2025-04-13 12:57:58 +02:00
parent b6a4cca884
commit 8763313c41
10 changed files with 185 additions and 171 deletions

View file

@ -4,6 +4,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/providers/api_provider.dart'; import 'package:twonly/src/providers/api_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/providers/connection_provider.dart';
import 'package:twonly/src/providers/hive.dart'; import 'package:twonly/src/providers/hive.dart';
import 'package:twonly/src/providers/settings_change_provider.dart'; import 'package:twonly/src/providers/settings_change_provider.dart';
import 'package:twonly/src/services/fcm_service.dart'; import 'package:twonly/src/services/fcm_service.dart';
@ -34,6 +35,7 @@ void main() async {
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider(create: (_) => settingsController), ChangeNotifierProvider(create: (_) => settingsController),
ChangeNotifierProvider(create: (_) => ConnectionChangeProvider()),
], ],
child: MyApp(), child: MyApp(),
), ),

View file

@ -1,6 +1,6 @@
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/components/connection_state.dart'; import 'package:twonly/src/providers/connection_provider.dart';
import 'package:twonly/src/providers/settings_change_provider.dart'; import 'package:twonly/src/providers/settings_change_provider.dart';
import 'package:twonly/src/services/notification_service.dart'; import 'package:twonly/src/services/notification_service.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
@ -31,7 +31,6 @@ class MyApp extends StatefulWidget {
} }
class _MyAppState extends State<MyApp> with WidgetsBindingObserver { class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
bool _isConnected = false;
bool wasPaused = false; bool wasPaused = false;
@override @override
@ -41,60 +40,18 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// register global callbacks to the widget tree // register global callbacks to the widget tree
globalCallbackConnectionState = (isConnected) { globalCallbackConnectionState = (update) {
setState(() { context.read<ConnectionChangeProvider>().updateConnectionState(update);
_isConnected = isConnected;
});
setupNotificationWithUsers(); setupNotificationWithUsers();
}; };
// WidgetsBinding.instance.addPostFrameCallback((_) {
// _requestPermissions();
// _initService();
// });
initAsync(); initAsync();
} }
Future initAsync() async { Future initAsync() async {
// make sure the front end service will be killed
// FlutterForegroundTask.sendDataToTask("");
// await FlutterForegroundTask.stopService();
// connect async to the backend api
apiProvider.connect(); apiProvider.connect();
} }
// Future<void> _requestPermissions() async {
// // Android 13+, you need to allow notification permission to display foreground service notification.
// //
// // iOS: If you need notification, ask for permission.
// final NotificationPermission notificationPermission =
// await FlutterForegroundTask.checkNotificationPermission();
// if (notificationPermission != NotificationPermission.granted) {
// await FlutterForegroundTask.requestNotificationPermission();
// }
// if (Platform.isAndroid) {
// // Android 12+, there are restrictions on starting a foreground service.
// //
// // To restart the service on device reboot or unexpected problem, you need to allow below permission.
// if (!await FlutterForegroundTask.isIgnoringBatteryOptimizations) {
// // This function requires `android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission.
// await FlutterForegroundTask.requestIgnoreBatteryOptimization();
// }
// // Use this utility only if you provide services that require long-term survival,
// // such as exact alarm service, healthcare service, or Bluetooth communication.
// //
// // This utility requires the "android.permission.SCHEDULE_EXACT_ALARM" permission.
// // Using this permission may make app distribution difficult due to Google policy.
// // if (!await FlutterForegroundTask.canScheduleExactAlarms) {
// // When you call this function, will be gone to the settings page.
// // So you need to explain to the user why set it.
// // await FlutterForegroundTask.openAlarmsAndRemindersSettings();
// // }
// }
// }
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
@ -108,11 +65,6 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
wasPaused = true; wasPaused = true;
globalIsAppInBackground = true; globalIsAppInBackground = true;
// apiProvider.close(() {
// use this only when uploading an image
// _startService();
// });
} }
} }
@ -165,11 +117,9 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
themeMode: context.watch<SettingsChangeProvider>().themeMode, themeMode: context.watch<SettingsChangeProvider>().themeMode,
initialRoute: '/', initialRoute: '/',
routes: { routes: {
"/": (context) => "/": (context) => MyAppMainWidget(initialPage: 0),
MyAppMainWidget(isConnected: _isConnected, initialPage: 0), "/chats": (context) => MyAppMainWidget(initialPage: 1)
"/chats": (context) => // home: MyAppMainWidget(isConnected: isConnected, initialPage: 0),
MyAppMainWidget(isConnected: _isConnected, initialPage: 1)
// home: MyAppMainWidget(isConnected: _isConnected, initialPage: 0),
}, },
); );
}, },
@ -178,10 +128,8 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
} }
class MyAppMainWidget extends StatefulWidget { class MyAppMainWidget extends StatefulWidget {
const MyAppMainWidget( const MyAppMainWidget({super.key, required this.initialPage});
{super.key, required this.isConnected, required this.initialPage});
final bool isConnected;
final int initialPage; final int initialPage;
@override @override
@ -201,7 +149,9 @@ class _MyAppMainWidgetState extends State<MyAppMainWidget> {
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
if (snapshot.data!) { if (snapshot.data!) {
return HomeView(initialPage: widget.initialPage); return HomeView(
initialPage: widget.initialPage,
);
} }
if (_showOnboarding) { if (_showOnboarding) {
@ -226,7 +176,6 @@ class _MyAppMainWidgetState extends State<MyAppMainWidget> {
} }
}, },
), ),
if (!widget.isConnected) ConnectionInfo()
], ],
); );
} }

View file

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
class ConnectionInfo extends StatefulWidget { class ConnectionInfo extends StatefulWidget {
const ConnectionInfo({super.key}); const ConnectionInfo({super.key});
@ -9,69 +8,87 @@ class ConnectionInfo extends StatefulWidget {
State<ConnectionInfo> createState() => _ConnectionInfoWidgetState(); State<ConnectionInfo> createState() => _ConnectionInfoWidgetState();
} }
class _ConnectionInfoWidgetState extends State<ConnectionInfo> { class _ConnectionInfoWidgetState extends State<ConnectionInfo>
int redColorOpacity = 100; // Initial opacity value with SingleTickerProviderStateMixin {
bool redColorGoUp = true; // Direction of the opacity change late AnimationController _controller;
double screenWidth = 0; // To hold the screen width late Animation<double> _positionAnim;
late Animation<double> _widthAnim;
Timer? _colorAnimationTimer; bool showAnimation = false;
final double minBarWidth = 40;
final double maxBarWidth = 150;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_startColorAnimation();
}
void _startColorAnimation() { _controller = AnimationController(
// Change the color every 200 milliseconds vsync: this,
_colorAnimationTimer = Timer.periodic(Duration(milliseconds: 200), (timer) { duration: const Duration(seconds: 4),
setState(() { );
if (redColorOpacity <= 100) {
redColorGoUp = true; _positionAnim = Tween<double>(begin: 0, end: 1).animate(
} CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
if (redColorOpacity >= 150) { );
redColorGoUp = false;
} _widthAnim = TweenSequence([
if (redColorGoUp) { TweenSequenceItem(
redColorOpacity += 10; tween: Tween<double>(begin: minBarWidth, end: maxBarWidth),
} else { weight: 50),
redColorOpacity -= 10; 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) {
_controller.repeat(reverse: true);
setState(() {
showAnimation = true;
});
}
}); });
} }
@override @override
void dispose() { void dispose() {
_colorAnimationTimer?.cancel(); _controller.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
screenWidth = MediaQuery.of(context).size.width; // Get the screen width if (!showAnimation) return Container();
double screenWidth = MediaQuery.of(context).size.width;
return Stack( return SizedBox(
children: [ width: screenWidth,
Positioned( height: 1,
top: 3, // Position it at the top child: AnimatedBuilder(
left: (screenWidth * 0.5) / 2, // Center it horizontally animation: _controller,
child: AnimatedContainer( builder: (context, child) {
duration: Duration(milliseconds: 100), double barWidth = _widthAnim.value;
width: screenWidth * 0.5, // 50% of the screen width double left = _positionAnim.value * (screenWidth - barWidth);
decoration: BoxDecoration( return Stack(
border: Border.all( children: [
color: Colors.red[600]! Positioned(
.withAlpha(redColorOpacity), // Use the animated opacity left: left,
width: 2.0, // Red border width top: 0,
bottom: 0,
child: Container(
width: barWidth,
decoration: BoxDecoration(
color: context.color.primary,
borderRadius: BorderRadius.circular(4),
),
),
), ),
borderRadius: BorderRadius.all( ],
Radius.circular(10.0), );
), // Rounded corners },
), ),
),
),
],
); );
} }
} }

View file

@ -238,8 +238,6 @@ class ImageUploader {
); );
if (wasSend.isError) { if (wasSend.isError) {
// await box.put("retransmit-$messageId-offset", 0);
// await box.delete("retransmit-$messageId-uploadtoken");
Logger("api.dart").shout("error while uploading media"); Logger("api.dart").shout("error while uploading media");
return null; return null;
} }

View file

@ -141,13 +141,13 @@ class ApiProvider {
isAuthenticated = false; isAuthenticated = false;
} }
void _onData(dynamic msgBuffer) { void _onData(dynamic msgBuffer) async {
try { try {
final msg = server.ServerToClient.fromBuffer(msgBuffer); final msg = server.ServerToClient.fromBuffer(msgBuffer);
if (msg.v0.hasResponse()) { if (msg.v0.hasResponse()) {
messagesV0[msg.v0.seq] = msg; messagesV0[msg.v0.seq] = msg;
} else { } else {
handleServerMessage(msg); await handleServerMessage(msg);
} }
} catch (e) { } catch (e) {
log.shout("Error parsing the servers message: $e"); log.shout("Error parsing the servers message: $e");

View file

@ -0,0 +1,10 @@
import 'package:flutter/foundation.dart';
class ConnectionChangeProvider with ChangeNotifier, DiagnosticableTreeMixin {
bool _isConnected = false;
bool get isConnected => _isConnected;
Future<void> updateConnectionState(bool update) async {
_isConnected = update;
notifyListeners();
}
}

View file

@ -19,6 +19,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension ShortCutsExtension on BuildContext { extension ShortCutsExtension on BuildContext {
AppLocalizations get lang => AppLocalizations.of(this)!; AppLocalizations get lang => AppLocalizations.of(this)!;
TwonlyDatabase get db => Provider.of<TwonlyDatabase>(this); TwonlyDatabase get db => Provider.of<TwonlyDatabase>(this);
ColorScheme get color => Theme.of(this).colorScheme;
} }
Future<void> writeLogToFile(LogRecord record) async { Future<void> writeLogToFile(LogRecord record) async {

View file

@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/components/connection_state.dart';
import 'package:twonly/src/components/flame.dart'; import 'package:twonly/src/components/flame.dart';
import 'package:twonly/src/components/initialsavatar.dart'; import 'package:twonly/src/components/initialsavatar.dart';
import 'package:twonly/src/components/message_send_state_icon.dart'; import 'package:twonly/src/components/message_send_state_icon.dart';
@ -12,6 +14,7 @@ import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/json_models/message.dart'; import 'package:twonly/src/json_models/message.dart';
import 'package:twonly/src/providers/api/media.dart'; import 'package:twonly/src/providers/api/media.dart';
import 'package:twonly/src/providers/connection_provider.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera_to_share/share_image_view.dart'; import 'package:twonly/src/views/camera_to_share/share_image_view.dart';
import 'package:twonly/src/views/chats/chat_item_details_view.dart'; import 'package:twonly/src/views/chats/chat_item_details_view.dart';
@ -31,6 +34,7 @@ class ChatListView extends StatefulWidget {
class _ChatListViewState extends State<ChatListView> { class _ChatListViewState extends State<ChatListView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool isConnected = context.watch<ConnectionChangeProvider>().isConnected;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("twonly"), title: Text("twonly"),
@ -71,60 +75,73 @@ class _ChatListViewState extends State<ChatListView> {
) )
], ],
), ),
body: StreamBuilder( body: Stack(
stream: twonlyDatabase.contactsDao.watchContactsForChatList(), children: [
builder: (context, snapshot) { Positioned(
if (!snapshot.hasData || snapshot.data == null) { top: 0,
return Container(); left: 0,
} right: 0,
child: isConnected ? Container() : ConnectionInfo(),
),
Positioned.fill(
child: StreamBuilder(
stream: twonlyDatabase.contactsDao.watchContactsForChatList(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return Container();
}
var contacts = snapshot.data!; var contacts = snapshot.data!;
if (contacts.isEmpty) { if (contacts.isEmpty) {
return Center( return Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: Icon(Icons.person_add), icon: Icon(Icons.person_add),
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => SearchUsernameView(), builder: (context) => SearchUsernameView(),
), ),
);
},
label:
Text(context.lang.chatListViewSearchUserNameBtn)),
),
);
}
int maxTotalMediaCounter = 0;
if (contacts.isNotEmpty) {
maxTotalMediaCounter = contacts
.map((x) => x.totalMediaCounter)
.reduce((a, b) => a > b ? a : b);
}
return RefreshIndicator(
onRefresh: () async {
await apiProvider.close(() {});
await apiProvider.connect();
await Future.delayed(Duration(seconds: 1));
},
child: ListView.builder(
restorationId: 'chat_list_view',
itemCount: contacts.length,
itemBuilder: (BuildContext context, int index) {
final user = contacts[index];
return UserListItem(
key: ValueKey(user.userId),
user: user,
maxTotalMediaCounter: maxTotalMediaCounter,
); );
}, },
label: Text(context.lang.chatListViewSearchUserNameBtn)), ),
),
);
}
int maxTotalMediaCounter = 0;
if (contacts.isNotEmpty) {
maxTotalMediaCounter = contacts
.map((x) => x.totalMediaCounter)
.reduce((a, b) => a > b ? a : b);
}
return RefreshIndicator(
onRefresh: () async {
await apiProvider.close(() {});
await apiProvider.connect();
await Future.delayed(Duration(seconds: 1));
},
child: ListView.builder(
restorationId: 'chat_list_view',
itemCount: contacts.length,
itemBuilder: (BuildContext context, int index) {
final user = contacts[index];
return UserListItem(
key: ValueKey(user.userId),
user: user,
maxTotalMediaCounter: maxTotalMediaCounter,
); );
}, },
), ),
); ),
}, ],
), ),
floatingActionButton: Padding( floatingActionButton: Padding(
padding: const EdgeInsets.only(bottom: 30.0), padding: const EdgeInsets.only(bottom: 30.0),

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart'; 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:pie_menu/pie_menu.dart'; import 'package:pie_menu/pie_menu.dart';
@ -34,6 +35,8 @@ class _StartNewChat extends State<StartNewChat> {
twonlyDatabase.contactsDao.watchContactsForShareView(); twonlyDatabase.contactsDao.watchContactsForShareView();
contactSub = stream.listen((update) { contactSub = stream.listen((update) {
update.sort((a, b) =>
getContactDisplayName(a).compareTo(getContactDisplayName(b)));
setState(() { setState(() {
allContacts = update; allContacts = update;
}); });
@ -83,6 +86,7 @@ class _StartNewChat extends State<StartNewChat> {
onChanged: (_) { onChanged: (_) {
filterUsers(); filterUsers();
}, },
controller: searchUserName,
decoration: getInputDecoration( decoration: getInputDecoration(
context, context,
context.lang.shareImageSearchAllContacts, context.lang.shareImageSearchAllContacts,
@ -116,21 +120,18 @@ class UserList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Step 1: Sort the users alphabetically
users
.sort((a, b) => b.lastMessageExchange.compareTo(a.lastMessageExchange));
return ListView.builder( return ListView.builder(
restorationId: 'new_message_users_list', restorationId: 'new_message_users_list',
itemCount: users.length + 2, itemCount: users.length + 2,
itemBuilder: (BuildContext context, int i) { itemBuilder: (BuildContext context, int i) {
if (i == 0) { if (i == 0) {
return ListTile( return ListTile(
key: Key("add_new_contact"),
title: Text(context.lang.startNewChatNewContact), title: Text(context.lang.startNewChatNewContact),
leading: CircleAvatar( leading: CircleAvatar(
child: FaIcon( child: FaIcon(
FontAwesomeIcons.userPlus, FontAwesomeIcons.userPlus,
size: 15, size: 13,
), ),
), ),
onTap: () { onTap: () {
@ -144,17 +145,17 @@ class UserList extends StatelessWidget {
); );
} }
if (i == 1) { if (i == 1) {
return HeadLineComponent(context.lang.startNewChatYourContacts); return Divider();
} }
Contact user = users[i - 2]; Contact user = users[i - 2];
int flameCounter = getFlameCounterFromContact(user); int flameCounter = getFlameCounterFromContact(user);
return UserContextMenu( return UserContextMenu(
key: Key(user.userId.toString()),
contact: user, contact: user,
child: ListTile( child: ListTile(
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.start, // Center horizontally mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.center,
CrossAxisAlignment.center, // Center vertically
children: [ children: [
Text(getContactDisplayName(user)), Text(getContactDisplayName(user)),
if (flameCounter >= 1) if (flameCounter >= 1)
@ -164,9 +165,25 @@ class UserList extends StatelessWidget {
maxTotalMediaCounter, maxTotalMediaCounter,
prefix: true, prefix: true,
), ),
Spacer(),
IconButton(
icon: FaIcon(FontAwesomeIcons.boxOpen,
size: 13,
color: user.archived ? null : Colors.transparent),
onPressed: user.archived
? () async {
final update =
ContactsCompanion(archived: Value(false));
await twonlyDatabase.contactsDao
.updateContact(user.userId, update);
}
: null)
], ],
), ),
leading: ContactAvatar(contact: user), leading: ContactAvatar(
contact: user,
fontSize: 13,
),
onTap: () { onTap: () {
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,

View file

@ -11,7 +11,10 @@ import 'package:flutter/material.dart';
Function(int) globalUpdateOfHomeViewPageIndex = (a) {}; Function(int) globalUpdateOfHomeViewPageIndex = (a) {};
class HomeView extends StatefulWidget { class HomeView extends StatefulWidget {
const HomeView({super.key, required this.initialPage}); const HomeView({
super.key,
required this.initialPage,
});
final int initialPage; final int initialPage;
@override @override