diff --git a/lib/src/app.dart b/lib/src/app.dart index 1bcd956..938989c 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -3,8 +3,8 @@ import 'views/home_view.dart'; import 'views/register_view.dart'; import 'utils.dart'; import 'package:flutter/material.dart'; -// import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -// import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'settings/settings_controller.dart'; @@ -34,16 +34,17 @@ class _MyAppState extends State { builder: (BuildContext context, Widget? child) { return MaterialApp( restorationScopeId: 'app', - // localizationsDelegates: const [ - // AppLocalizations.delegate, - // GlobalMaterialLocalizations.delegate, - // GlobalWidgetsLocalizations.delegate, - // GlobalCupertinoLocalizations.delegate, - // ], - supportedLocales: const [ - Locale('en', ''), // English, no country code + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, ], - onGenerateTitle: (BuildContext context) => "Connect!", + supportedLocales: const [ + Locale('en', ''), + Locale('de', ''), + ], + onGenerateTitle: (BuildContext context) => "twonly", theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF57CC99)), diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb new file mode 100644 index 0000000..8f0749a --- /dev/null +++ b/lib/src/localization/app_de.arb @@ -0,0 +1,6 @@ +{ + "@@locale": "de", + "registerTitle": "Willkommen bei twonly", + "@registerTitle": {}, + "registerSlogan": "Sende Bilder in Echtzeit an Freunde und sei dir sicher, dass nur ihr sie sehen könnt." +} \ No newline at end of file diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 241eb64..338b51c 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -1,7 +1,22 @@ { - "appTitle": "connect", "@@locale": "en", - "@appTitle": { - "description": "The title of the application" - } + "registerTitle": "Welcome to twonly!", + "registerSlogan": "Send pictures to friends in real time and be sure you are the only people who can see them.", + "registerUsernameSlogan": "Please select a username so others can find you!", + "registerUsernameDecoration": "Username", + "registerUsernameLimits": "Username must be 4 to 12 characters long, consisting only of letters (a-z) and numbers (0-9).", + "registerSubmitButton": "Register now!", + "errorUnknown": "An unexpected error has occurred. Please try again later.", + "errorBadRequest": "The request could not be understood by the server due to malformed syntax. Please check your input and try again.", + "errorTooManyRequests": "You have made too many requests in a short period. Please wait a moment before trying again.", + "errorInternalError": "The server encountered an internal error. Please try again later.", + "errorInvalidInvitationCode": "The invitation code you provided is invalid. Please check the code and try again.", + "errorUsernameAlreadyTaken": "The username you want to use is already taken. Please choose a different username.", + "errorSignatureNotValid": "The provided signature is not valid. Please check your credentials and try again.", + "errorUsernameNotFound": "The username you entered does not exist. Please check the spelling or create a new account.", + "errorUsernameNotValid": "The username you provided does not meet the required criteria. Please choose a valid username.", + "errorInvalidPublicKey": "The public key you provided is invalid. Please check the key and try again.", + "errorSessionAlreadyAuthenticated": "You are already logged in. Please log out if you want to log in with a different account.", + "errorSessionNotAuthenticated": "Your session is not authenticated. Please log in to continue.", + "errorOnlyOneSessionAllowed": "Only one active session is allowed per user. Please log out from other devices to continue." } \ No newline at end of file diff --git a/lib/src/providers/api_provider.dart b/lib/src/providers/api_provider.dart index 1545957..c22dd28 100644 --- a/lib/src/providers/api_provider.dart +++ b/lib/src/providers/api_provider.dart @@ -4,13 +4,27 @@ import 'dart:ffi'; import 'dart:math'; import 'dart:typed_data'; import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; import 'package:twonly/src/proto/api/client_to_server.pb.dart'; +import 'package:twonly/src/proto/api/error.pb.dart'; import 'package:twonly/src/proto/api/server_to_client.pb.dart' as server; import 'package:twonly/src/signal/signal_helper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +class Result { + final T? value; + final E? error; + + bool get isSuccess => value != null; + bool get isError => error != null; + + Result.success(this.value) : error = null; + Result.error(this.error) : value = null; +} + class ApiProvider { ApiProvider({required this.apiUrl}); @@ -77,7 +91,7 @@ class ApiProvider { log.shout("Timeout for message $seq"); return null; } - await Future.delayed(Duration(milliseconds: 1)); + await Future.delayed(Duration(milliseconds: 10)); } } @@ -101,21 +115,6 @@ class ApiProvider { return await _waitForResponse(seq); } - String? _getErrorMsg(server.ServerToClient msg) { - // if (msg.containsKey("Ok")) { - // return null; - // } - // if (msg.containsKey("Error")) { - // if (msg["Error"] != null) { - // if (msg["Error"].containsKey("AlertUser")) { - // return msg["Error"]["AlertUser"]; - // } - // } - // return "There was an unknown error :/"; - // } - return null; - } - ClientToServer createClientToServerFromHandshake(Handshake handshake) { // Create the V0 message var v0 = V0() @@ -128,27 +127,63 @@ class ApiProvider { return clientToServer; } - Future register( - String username, - // Uint8List publicIdentityKey, - // Uint8List signedPrekey, - // Uint8List signedPrekeySignature, - String? inviteCode) async { + static String getLocalizedString(BuildContext context, ErrorCode code) { + switch (code.toString()) { + case "Unknown": + return AppLocalizations.of(context)!.errorUnknown; + case "BadRequest": + return AppLocalizations.of(context)!.errorBadRequest; + case "TooManyRequests": + return AppLocalizations.of(context)!.errorTooManyRequests; + case "InternalError": + return AppLocalizations.of(context)!.errorInternalError; + case "InvalidInvitationCode": + return AppLocalizations.of(context)!.errorInvalidInvitationCode; + case "UsernameAlreadyTaken": + return AppLocalizations.of(context)!.errorUsernameAlreadyTaken; + case "SignatureNotValid": + return AppLocalizations.of(context)!.errorSignatureNotValid; + case "UsernameNotFound": + return AppLocalizations.of(context)!.errorUsernameNotFound; + case "UsernameNotValid": + return AppLocalizations.of(context)!.errorUsernameNotValid; + case "InvalidPublicKey": + return AppLocalizations.of(context)!.errorInvalidPublicKey; + case "SessionAlreadyAuthenticated": + return AppLocalizations.of(context)!.errorSessionAlreadyAuthenticated; + case "SessionNotAuthenticated": + return AppLocalizations.of(context)!.errorSessionNotAuthenticated; + case "OnlyOneSessionAllowed": + return AppLocalizations.of(context)!.errorOnlyOneSessionAllowed; + default: + return code.toString(); // Fallback for unrecognized keys + } + } + + Result _asResult(server.ServerToClient msg) { + if (msg.v0.response.hasOk()) { + return Result.success(msg.v0.response.ok); + } else { + return Result.error(msg.v0.response.error); + } + } + + Future register(String username, String? inviteCode) async { final reqSignal = await SignalHelper.getRegisterData(); if (reqSignal == null) { - print("NULL"); - return null; + return Result.error( + "There was an fatal error. Try reinstalling the app."); } - print(reqSignal); var register = Handshake_Register() ..username = username + ..publicIdentityKey = reqSignal["identityKey"] ..signedPrekey = reqSignal["signedPreKey"]?["key"] ..signedPrekeySignature = reqSignal["signedPreKey"]?["signature"] ..signedPrekeyId = Int64(reqSignal["signedPreKey"]?["id"]); - if (inviteCode != null) { + if (inviteCode != null && inviteCode != "") { register.inviteCode = inviteCode; } // Create the Handshake message @@ -157,8 +192,8 @@ class ApiProvider { final resp = await _sendRequestV0(req); if (resp == null) { - return "Server is not reachable!"; + return Result.error("Server is not reachable!"); } - return _getErrorMsg(resp); + return _asResult(resp); } } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 57c6471..f7af799 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,9 +1,10 @@ import 'dart:convert'; import 'package:twonly/main.dart'; import 'package:twonly/src/signal/signal_helper.dart'; +import 'package:twonly/src/providers/api_provider.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'model/signal_identity_json.dart'; import 'model/user_data_json.dart'; +import 'package:logging/logging.dart'; // Just a helper function to get the secure storage FlutterSecureStorage getSecureStorage() { @@ -38,24 +39,25 @@ Future getUser() async { Future deleteLocalUserData() async { final storage = getSecureStorage(); - await storage.delete(key: "user"); + await storage.delete(key: "user_data"); + await storage.delete(key: "signal_identity"); return true; } -Future createNewUser(String username, String inviteCode) async { +Future createNewUser(String username, String inviteCode) async { final storage = getSecureStorage(); await SignalHelper.createIfNotExistsSignalIdentity(); // TODO: API call to server to check username and inviteCode - final check = await apiProvider.register(username, inviteCode); - if (check != null) { - return check; + final res = await apiProvider.register(username, inviteCode); + + if (res.isSuccess) { + print("Got user_id ${res.value}"); + final userData = UserData( + userId: res.value.userid, username: username, displayName: username); + storage.write(key: "user_data", value: jsonEncode(userData)); } - print(check); - - // await storage.write(key: "user_data", value: jsonEncode(userData)); - - return null; + return res; } diff --git a/lib/src/views/register_view.dart b/lib/src/views/register_view.dart index 699384d..6577594 100644 --- a/lib/src/views/register_view.dart +++ b/lib/src/views/register_view.dart @@ -1,6 +1,9 @@ +import 'package:twonly/src/providers/api_provider.dart'; + import '../utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class RegisterView extends StatefulWidget { const RegisterView({super.key, required this.callbackOnSuccess}); @@ -10,36 +13,12 @@ class RegisterView extends StatefulWidget { State createState() => _RegisterViewState(); } -class MyButton extends StatelessWidget { - final void Function()? onTap; - final String text; - const MyButton({super.key, required this.onTap, required this.text}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(9), - ), - child: Center( - child: Text( - text, - textAlign: TextAlign.center, - ), - ), - ), - ); - } -} - class _RegisterViewState extends State { final TextEditingController usernameController = TextEditingController(); final TextEditingController inviteCodeController = TextEditingController(); + bool _isTryingToRegister = false; + @override Widget build(BuildContext context) { InputDecoration getInputDecoration(hintText) { @@ -48,77 +27,136 @@ class _RegisterViewState extends State { return Scaffold( appBar: AppBar( - title: Text("Welcome to Connect!"), + title: Text(""), ), body: Padding( padding: EdgeInsets.all(10), - child: ListView( - children: [ - const SizedBox(height: 20), - Center( - child: Text( - "You made the right decision using Connect which is like SnXpchat but encrypted using the Signal protocol.", + child: Padding( + padding: EdgeInsets.only(left: 10, right: 10), + child: ListView( + children: [ + const SizedBox(height: 50), + Text( + AppLocalizations.of(context)!.registerTitle, textAlign: TextAlign.center, - style: TextStyle(fontSize: 15), + style: TextStyle(fontSize: 30), ), - ), - const SizedBox(height: 40), - Center( - child: Text( - "Choice wisely, this username can't be changed. Only lowercase and numbers are allowed!", - textAlign: TextAlign.center, + Padding( + padding: EdgeInsets.symmetric(horizontal: 30), + child: Text( + AppLocalizations.of(context)!.registerSlogan, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), ), - ), - const SizedBox(height: 10), - TextField( + const SizedBox(height: 60), + Center( + child: Padding( + padding: EdgeInsets.only(left: 10, right: 10), + child: Text( + AppLocalizations.of(context)!.registerUsernameSlogan, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15), + ), + ), + ), + const SizedBox(height: 15), + TextField( controller: usernameController, inputFormatters: [ - LengthLimitingTextInputFormatter( - 12), // Limit to 12 characters - FilteringTextInputFormatter.allow(RegExp( - r'[a-z0-9]')), // Allow only lowercase letters and numbers + LengthLimitingTextInputFormatter(12), + FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9]')), ], - decoration: getInputDecoration("Username")), - const SizedBox(height: 15), - Center( - child: Text( - "To protect this small experimental project you need an invitation code! To get one just ask the right person!", - textAlign: TextAlign.center, + style: TextStyle(fontSize: 17), + decoration: getInputDecoration( + AppLocalizations.of(context)!.registerUsernameDecoration, + ), ), - ), - const SizedBox(height: 10), - TextField( - controller: inviteCodeController, - decoration: getInputDecoration("Invitation code")), - const SizedBox(height: 25), - Center( - child: Text( - "Where is the password? There is none! So make a backup of your Connect identity in the settings or you will lose your access if you lose your device!", - textAlign: TextAlign.center, + const SizedBox(height: 5), + Center( + child: Padding( + padding: EdgeInsets.only(left: 10, right: 10), + child: Text( + AppLocalizations.of(context)!.registerUsernameLimits, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 7), + ), + ), ), - ), - const SizedBox(height: 30), - FilledButton.icon( - icon: Icon(Icons.group), - onPressed: () async { - final success = await createNewUser( - usernameController.text, inviteCodeController.text); - if (success == null) { - widget.callbackOnSuccess(); - return; - } - showAlertDialog(context, "Oh no!", success); - }, - label: Text("Komm in die Gruppe!"), - ), - OutlinedButton.icon( - onPressed: () { - showAlertDialog(context, "Coming soon", - "This feature is not yet implemented! Just create a new account :/"); - }, - label: Text("Restore identity")), - // MyButton(onTap: () {}, text: "Komm in die Gruppe!") - ], + // const SizedBox(height: 15), + // Center( + // child: Text( + // "To protect this small experimental project you need an invitation code! To get one just ask the right person!", + // textAlign: TextAlign.center, + // ), + // ), + // const SizedBox(height: 10), + // TextField( + // controller: inviteCodeController, + // decoration: getInputDecoration("Voucher code")), + // const SizedBox(height: 25), + // Center( + // child: Text( + // "Please ", + // textAlign: TextAlign.center, + // ), + // ), + const SizedBox(height: 50), + // Padding( + // padding: EdgeInsets.symmetric(horizontal: 10), + Column(children: [ + FilledButton.icon( + icon: _isTryingToRegister + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.black, + strokeWidth: 2, + ), + ) + : Icon(Icons.group), + onPressed: () async { + setState(() { + _isTryingToRegister = true; + }); + final res = await createNewUser( + usernameController.text, inviteCodeController.text); + setState(() { + _isTryingToRegister = false; + }); + if (res.isSuccess) { + widget.callbackOnSuccess(); + return; + } + final errMsg = + ApiProvider.getLocalizedString(context, res.error); + showAlertDialog(context, "Oh no!", errMsg); + }, + style: ButtonStyle( + padding: WidgetStateProperty.all( + EdgeInsets.symmetric(vertical: 10, horizontal: 30), + ), + backgroundColor: _isTryingToRegister + ? WidgetStateProperty.all( + Colors.grey) + : null), + label: Text( + AppLocalizations.of(context)!.registerSubmitButton, + style: TextStyle(fontSize: 17), + ), + ), + const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: () { + showAlertDialog(context, "Coming soon", + "This feature is not yet implemented! Just create a new account :/"); + }, + label: Text("Restore identity")), + ]), + // ), + ], + ), )), ); } diff --git a/pubspec.lock b/pubspec.lock index 2d5f919..c1906fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -475,7 +475,7 @@ packages: source: hosted version: "4.5.2" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf diff --git a/pubspec.yaml b/pubspec.yaml index cc754b3..6ce8106 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: flutter_secure_storage: ^9.2.2 google_fonts: ^6.2.1 image: ^4.3.0 + intl: any json_annotation: ^4.9.0 libsignal_protocol_dart: ^0.7.1 logging: ^1.3.0