From daa90fe2f49122b12ed39dacddc595d4297b2f4d Mon Sep 17 00:00:00 2001 From: iphydf Date: Sun, 26 Jan 2025 18:52:46 +0000 Subject: [PATCH] feat: Add support for multiple profiles. Also basically rewrote everything and moved to riverpod. --- .github/workflows/ci.yml | 8 +- .github/workflows/pages.yml | 2 +- .gitignore | 11 +- cspell.config.yaml | 17 +- ios/Podfile.lock | 20 +- lib/btox_actions.dart | 11 - lib/btox_app.dart | 74 ++++-- lib/btox_reducer.dart | 18 -- lib/btox_state.dart | 23 -- lib/db/database.dart | 104 ++++++-- lib/db/native.dart | 19 +- lib/db/unsupported.dart | 2 +- lib/db/web.dart | 16 +- lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 91 ++++++- lib/logger.dart | 112 ++++++++ lib/main.dart | 22 +- lib/models/id.dart | 47 ++++ lib/models/persistence.dart | 31 +++ lib/models/profile_settings.dart | 44 ++++ lib/models/tox_constants.dart | 16 ++ lib/{ => pages}/add_contact_page.dart | 47 ++-- lib/{ => pages}/chat_page.dart | 43 ++-- lib/{ => pages}/contact_list_page.dart | 139 +++++----- lib/pages/create_profile_page.dart | 74 ++++++ lib/pages/select_profile_page.dart | 56 ++++ lib/pages/settings_page.dart | 74 ++++++ lib/pages/user_profile_page.dart | 85 +++++++ lib/profile.dart | 136 ---------- lib/providers/database.dart | 18 ++ lib/settings.dart | 15 -- lib/widgets/contact_list_item.dart | 24 ++ lib/widgets/friend_request_message_field.dart | 7 + lib/widgets/nickname_field.dart | 46 ++++ lib/widgets/status_message_field.dart | 45 ++++ lib/widgets/tox_id_field.dart | 7 +- macos/Podfile.lock | 20 +- netlify.toml | 2 +- pubspec.lock | 240 +++++++++++++++--- pubspec.yaml | 28 +- test/.gitignore | 1 + test/btox_app.png | Bin 0 -> 27859 bytes test/widget_test.dart | 29 ++- tools/prepare-web | 8 +- 44 files changed, 1340 insertions(+), 493 deletions(-) delete mode 100644 lib/btox_actions.dart delete mode 100644 lib/btox_reducer.dart delete mode 100644 lib/btox_state.dart create mode 100644 lib/logger.dart create mode 100644 lib/models/id.dart create mode 100644 lib/models/persistence.dart create mode 100644 lib/models/profile_settings.dart create mode 100644 lib/models/tox_constants.dart rename lib/{ => pages}/add_contact_page.dart (51%) rename lib/{ => pages}/chat_page.dart (69%) rename lib/{ => pages}/contact_list_page.dart (57%) create mode 100644 lib/pages/create_profile_page.dart create mode 100644 lib/pages/select_profile_page.dart create mode 100644 lib/pages/settings_page.dart create mode 100644 lib/pages/user_profile_page.dart delete mode 100644 lib/profile.dart create mode 100644 lib/providers/database.dart delete mode 100644 lib/settings.dart create mode 100644 lib/widgets/contact_list_item.dart create mode 100644 lib/widgets/nickname_field.dart create mode 100644 lib/widgets/status_message_field.dart create mode 100644 test/.gitignore create mode 100644 test/btox_app.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be8770..e468ac0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: branches: [master] env: - FLUTTER_VERSION: "3.27.1" + FLUTTER_VERSION: "3.27.3" jobs: common: @@ -48,6 +48,12 @@ jobs: - run: ./tools/prepare - run: flutter test --coverage - run: bash <(curl -s https://codecov.io/bash) + - name: Upload failed test goldens + if: failure() + uses: actions/upload-artifact@v4 + with: + name: failed-test-goldens + path: test/failures android-build: runs-on: ubuntu-24.04 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index bb262b3..a16f93f 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: env: - FLUTTER_VERSION: "3.27.1" + FLUTTER_VERSION: "3.27.3" jobs: deploy-pages: diff --git a/.gitignore b/.gitignore index 0fa6b67..167ee47 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,12 @@ *.swp .DS_Store .atom/ +.build/ .buildlog/ .history .svn/ +.swiftpm/ +migrate_working_dir/ # IntelliJ related *.iml @@ -26,14 +29,10 @@ .dart_tool/ .flutter-plugins .flutter-plugins-dependencies -.packages .pub-cache/ .pub/ /build/ -# Web related -lib/generated_plugin_registrant.dart - # Symbolication related app.*.symbols @@ -44,3 +43,7 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Generated files +*.freezed.dart +*.g.dart diff --git a/cspell.config.yaml b/cspell.config.yaml index fd48fa6..5adbc64 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -5,13 +5,14 @@ ignoreWords: [] flagWords: [] import: [] ignorePaths: - - ".github/**" - - "build/**" - - "LICENSE*" + - .github/** + - build/** + - LICENSE* - "*.json" words: - - "btox" - - "iphydf" - - "kannywood" - - "robinlinden" - - "yanciman" + - btox + - iphydf + - kannywood + - nospam + - robinlinden + - yanciman diff --git a/ios/Podfile.lock b/ios/Podfile.lock index bb6ab14..bf3364a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,21 +3,21 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (3.47.2): - - sqlite3/common (= 3.47.2) - - sqlite3/common (3.47.2) - - sqlite3/dbstatvtab (3.47.2): + - sqlite3 (3.48.0): + - sqlite3/common (= 3.48.0) + - sqlite3/common (3.48.0) + - sqlite3/dbstatvtab (3.48.0): - sqlite3/common - - sqlite3/fts5 (3.47.2): + - sqlite3/fts5 (3.48.0): - sqlite3/common - - sqlite3/perf-threadsafe (3.47.2): + - sqlite3/perf-threadsafe (3.48.0): - sqlite3/common - - sqlite3/rtree (3.47.2): + - sqlite3/rtree (3.48.0): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.47.2) + - sqlite3 (~> 3.48.0) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/perf-threadsafe @@ -43,8 +43,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - sqlite3: 7559e33dae4c78538df563795af3a86fc887ee71 - sqlite3_flutter_libs: 5235ce0546528db87927a3ef1baff8b7d5107f0e + sqlite3: 3da10a59910c809fb584a93aa46a3f05b785e12e + sqlite3_flutter_libs: c26d86af4ad88f1465dc4e07e6dc6931eef228e4 PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 diff --git a/lib/btox_actions.dart b/lib/btox_actions.dart deleted file mode 100644 index 35c7216..0000000 --- a/lib/btox_actions.dart +++ /dev/null @@ -1,11 +0,0 @@ -class BtoxUpdateNicknameAction { - final String nickname; - - const BtoxUpdateNicknameAction(this.nickname); -} - -class BtoxUpdateStatusMessageAction { - final String statusMessage; - - const BtoxUpdateStatusMessageAction(this.statusMessage); -} diff --git a/lib/btox_app.dart b/lib/btox_app.dart index ccf1f88..51be301 100644 --- a/lib/btox_app.dart +++ b/lib/btox_app.dart @@ -1,37 +1,57 @@ -import 'package:btox/btox_state.dart'; -import 'package:btox/contact_list_page.dart'; import 'package:btox/db/database.dart'; +import 'package:btox/pages/contact_list_page.dart'; +import 'package:btox/pages/create_profile_page.dart'; +import 'package:btox/pages/select_profile_page.dart'; +import 'package:btox/providers/database.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -import 'package:redux/redux.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -final class BtoxApp extends StatelessWidget { - final Database database; - final Store store; - - const BtoxApp({ - super.key, - required this.database, - required this.store, - }); +final class BtoxApp extends ConsumerWidget { + const BtoxApp({super.key}); @override - Widget build(BuildContext context) { - return StoreProvider( - store: store, - child: MaterialApp( - title: 'bTox', - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - theme: ThemeData( - brightness: Brightness.dark, - primarySwatch: Colors.blue, - ), - home: ContactListPage( - database: database, - ), + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp( + title: 'bTox', + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData( + brightness: Brightness.dark, + primarySwatch: Colors.blue, ), + home: ref.watch(databaseProvider).when( + loading: () => const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ), + error: (error, _) => Scaffold( + body: Center( + child: Text('Error: $error'), + ), + ), + data: (db) => StreamBuilder>( + stream: db.watchProfiles(), + builder: (context, snapshot) { + final profiles = snapshot.data ?? const []; + if (profiles.isEmpty) { + return const CreateProfilePage(); + } + + final activeProfiles = + profiles.where((profile) => profile.active).toList(); + if (activeProfiles.isEmpty) { + return SelectProfilePage(profiles: profiles); + } + + return ContactListPage( + database: db, + profile: activeProfiles.first, + ); + }, + ), + ), ); } } diff --git a/lib/btox_reducer.dart b/lib/btox_reducer.dart deleted file mode 100644 index e735653..0000000 --- a/lib/btox_reducer.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:btox/btox_state.dart'; -import 'package:btox/btox_actions.dart'; -import 'package:redux/redux.dart'; - -final btoxReducer = combineReducers([ - TypedReducer(_updateNickname).call, - TypedReducer(_updateStatusMessage) - .call, -]); - -BtoxState _updateNickname(BtoxState state, BtoxUpdateNicknameAction action) { - return state.copyWith(nickname: action.nickname); -} - -BtoxState _updateStatusMessage( - BtoxState state, BtoxUpdateStatusMessageAction action) { - return state.copyWith(statusMessage: action.statusMessage); -} diff --git a/lib/btox_state.dart b/lib/btox_state.dart deleted file mode 100644 index c6516a4..0000000 --- a/lib/btox_state.dart +++ /dev/null @@ -1,23 +0,0 @@ -final class BtoxState { - final String nickname; - final String statusMessage; - - const BtoxState({ - required this.nickname, - required this.statusMessage, - }); - - const BtoxState.initial() - : nickname = 'Yanciman', - statusMessage = 'Producing works of art in Kannywood'; - - BtoxState copyWith({ - String? nickname, - String? statusMessage, - }) { - return BtoxState( - nickname: nickname ?? this.nickname, - statusMessage: statusMessage ?? this.statusMessage, - ); - } -} diff --git a/lib/db/database.dart b/lib/db/database.dart index 53b07a5..5189d6b 100644 --- a/lib/db/database.dart +++ b/lib/db/database.dart @@ -1,39 +1,97 @@ +import 'package:btox/models/id.dart'; +import 'package:btox/models/persistence.dart'; +import 'package:btox/models/profile_settings.dart'; import 'package:drift/drift.dart'; -part 'database.g.dart'; - -class Contacts extends Table { - IntColumn get id => integer().autoIncrement()(); - TextColumn get publicKey => text()(); - TextColumn get name => text().nullable()(); -} +export 'package:drift/drift.dart' show Value; -class Messages extends Table { - IntColumn get contactId => integer().references(Contacts, #id)(); - TextColumn get content => text()(); - DateTimeColumn get timestamp => dateTime()(); -} +part 'database.g.dart'; -@DriftDatabase(tables: [Contacts, Messages]) +@DriftDatabase(tables: [ + Contacts, + Messages, + Profiles, +]) final class Database extends _$Database { Database(super.e); - @override - int get schemaVersion => 2; - // TODO(robinlinden): Remove before first real release. @override MigrationStrategy get migration => destructiveFallback; - void addContact(ContactsCompanion entry) => into(contacts).insert(entry); + @override + int get schemaVersion => 1; + + Future activateProfile(Id id) async { + await deactivateProfiles(); + + await (update(profiles)..where((p) => p.id.equals(id.value))) + .write(ProfilesCompanion( + active: Value(true), + )); + } + + Future deactivateProfiles() async { + await update(profiles).write(ProfilesCompanion( + active: Value(false), + )); + } + + Future deleteProfile(Id id) async { + transaction(() async { + // Find all the contacts for the profile. + final contactsForProfile = await (select(contacts) + ..where((c) => c.profileId.equals(id.value))) + .get(); + // Delete all their messages. + batch((batch) { + for (final contact in contactsForProfile) { + batch.deleteWhere( + messages, + (m) => m.contactId.equals(contact.id.value), + ); + } + }); + // Then delete the contacts. + await (delete(contacts)..where((c) => c.profileId.equals(id.value))).go(); + // Finally delete the profile. + await (delete(profiles)..where((p) => p.id.equals(id.value))).go(); + }); + } + + Future> addContact(ContactsCompanion entry) async => + Id(await into(contacts).insert(entry)); + + Future> addMessage(MessagesCompanion entry) async => + Id(await into(messages).insert(entry)); + + Future> addProfile(ProfilesCompanion entry) async => + Id(await into(profiles).insert(entry)); + + Future updateProfileSettings( + Id id, ProfileSettings settings) async { + await (update(profiles)..where((p) => p.id.equals(id.value))).write( + ProfilesCompanion( + settings: Value(settings), + ), + ); + } + + Stream watchContact(Id id) => + (select(contacts)..where((c) => c.id.equals(id.value))).watchSingle(); - Stream> watchContacts() => select(contacts).watch(); + Stream> watchContactsFor(Id id) => (select(contacts) + ..where((c) => c.profileId.equals(id.value)) + ..orderBy([ + (c) => OrderingTerm(expression: c.name), + ])) + .watch(); - Stream watchContact(int id) => - (select(contacts)..where((c) => c.id.equals(id))).watchSingle(); + Stream> watchMessagesFor(Id id) => + (select(messages)..where((m) => m.contactId.equals(id.value))).watch(); - void addMessage(MessagesCompanion entry) => into(messages).insert(entry); + Stream watchProfile(Id id) => + (select(profiles)..where((p) => p.id.equals(id.value))).watchSingle(); - Stream> watchMessagesFor(int id) => - (select(messages)..where((m) => m.contactId.equals(id))).watch(); + Stream> watchProfiles() => select(profiles).watch(); } diff --git a/lib/db/native.dart b/lib/db/native.dart index 9cee3b6..c280703 100644 --- a/lib/db/native.dart +++ b/lib/db/native.dart @@ -1,17 +1,18 @@ import 'dart:io'; import 'package:btox/db/database.dart'; -import 'package:drift/drift.dart'; +import 'package:btox/logger.dart'; import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; -Database constructDb() { - return Database( - LazyDatabase(() async { - final dbFolder = await getApplicationDocumentsDirectory(); - final file = File(path.join(dbFolder.path, 'db.sqlite')); - return NativeDatabase(file); - }), - ); +const _logger = Logger(['NativeDatabase']); + +Future constructDb() async { + WidgetsFlutterBinding.ensureInitialized(); + final dbFolder = await getApplicationDocumentsDirectory(); + final file = File(path.join(dbFolder.path, 'db.sqlite')); + _logger.d('Database file: ${file.path}'); + return Database(NativeDatabase(file)); } diff --git a/lib/db/unsupported.dart b/lib/db/unsupported.dart index 1ac737c..e5801dd 100644 --- a/lib/db/unsupported.dart +++ b/lib/db/unsupported.dart @@ -1,3 +1,3 @@ import 'package:btox/db/database.dart'; -Database constructDb() => throw UnimplementedError(); +Future constructDb() => throw UnimplementedError(); diff --git a/lib/db/web.dart b/lib/db/web.dart index 0418c56..1a6505d 100644 --- a/lib/db/web.dart +++ b/lib/db/web.dart @@ -1,11 +1,11 @@ import 'package:btox/db/database.dart'; -import 'package:drift/drift.dart'; import 'package:drift/wasm.dart'; -Database constructDb() => Database( - DatabaseConnection.delayed(Future(() async => (await WasmDatabase.open( - databaseName: 'db', - sqlite3Uri: Uri(path: 'sqlite3.wasm'), - driftWorkerUri: Uri(path: 'drift_worker.js'), - )) - .resolvedExecutor))); +Future constructDb() async { + final db = await WasmDatabase.open( + databaseName: 'db', + sqlite3Uri: Uri(path: 'sqlite3.wasm'), + driftWorkerUri: Uri(path: 'drift_worker.js'), + ); + return Database(db.resolvedExecutor); +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 209df7f..06a3e65 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1,3 +1,4 @@ { + "@@locale": "de", "helloWorld": "Hallo Welt!" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 418b92e..2642abc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,7 +1,8 @@ { + "@@locale": "en", "defaultContactName": "Unknown", "@defaultContactName": { - "description": "The default name for a contact when the name is not known" + "description": "The default name for a contact when the name is not yet known" }, "title": "bTox (working title)", "@title": { @@ -33,13 +34,33 @@ } } }, + "addContactMessageLengthError": "Maximum {max} characters ({length} is too long)", + "@addContactMessageLengthError": { + "description": "The error message when the friend request message is too long", + "placeholders": { + "max": { + "type": "int", + "example": "256" + }, + "length": { + "type": "int", + "example": "300" + } + } + }, "add": "Add", "@add": { "description": "The text for the button to add a contact" }, - "toxIdLengthError": "The Tox ID must be 76 characters", + "toxIdLengthError": "Must be {length} hexadecimal characters", "@toxIdLengthError": { - "description": "The error message when the Tox ID is not 76 characters long" + "description": "The error message when the Tox ID is not the right length or format", + "placeholders": { + "length": { + "type": "int", + "example": "76" + } + } }, "messageEmptyError": "The message can't be empty", "@messageEmptyError": { @@ -57,13 +78,25 @@ "@menuSettings": { "description": "The text for the settings menu item" }, - "nickLengthError": "Nickname must be between 1 and 32 characters", + "nickLengthError": "Nickname must be between 1 and {max} characters", "@nickLengthError": { - "description": "The error message when the nickname is not between 1 and 32 characters long" + "description": "The error message when the nickname is too short or too long", + "placeholders": { + "max": { + "type": "int", + "example": "32" + } + } }, - "statusMessageLengthError": "Status message may not exceed 256 characters", + "statusMessageLengthError": "Status message may not exceed {max} characters", "@statusMessageLengthError": { - "description": "The error message when the status message is longer than 256 characters" + "description": "The error message when the status message is longer than the supported maximum", + "placeholders": { + "max": { + "type": "int", + "example": "128" + } + } }, "profileTextFieldNick": "Nickname", "@profileTextFieldNick": { @@ -80,5 +113,49 @@ "applied": "Applied", "@applied": { "description": "The text when changes have been applied" + }, + "newProfile": "New profile", + "@newProfile": { + "description": "The title text for the create profile dialog" + }, + "create": "Create", + "@create": { + "description": "The text for the button create a new profile" + }, + "logout": "Logout", + "@logout": { + "description": "The text for the logout menu item" + }, + "deleteProfile": "Delete profile", + "@deleteProfile": { + "description": "The text for the delete profile menu item" + }, + "deleteProfileMessage": "Are you sure you want to delete your '{name}' profile? This action cannot be undone. It will delete all your contacts and messages. Make sure you have a backup.", + "@deleteProfileMessage": { + "description": "The message to confirm the deletion of a profile", + "placeholders": { + "name": { + "type": "String", + "example": "Alice" + } + } + }, + "cancel": "Cancel", + "@cancel": { + "description": "The text for the cancel button" + }, + "delete": "Delete", + "@delete": { + "description": "The text for the delete button" + }, + "profileDeleted": "Profile '{name}' deleted", + "@profileDeleted": { + "description": "The text when a profile has been deleted", + "placeholders": { + "name": { + "type": "String", + "example": "Alice" + } + } } } diff --git a/lib/logger.dart b/lib/logger.dart new file mode 100644 index 0000000..d041cdc --- /dev/null +++ b/lib/logger.dart @@ -0,0 +1,112 @@ +import 'dart:math'; + +import 'package:clock/clock.dart'; +import 'package:circular_buffer/circular_buffer.dart'; +import 'package:flutter/foundation.dart'; + +class _StackFrame { + final String file; + final int line; + final int column; + + const _StackFrame(this.file, this.line, this.column); + + @override + String toString() => '$file:$line:$column'; + + static _StackFrame? fromString(String frame) { + final match = + RegExp(r'packages?[:/]([^ :]+)[: ](\d+):(\d+)').firstMatch(frame); + if (match == null) return null; + return _StackFrame( + match.group(1)!, + int.parse(match.group(2)!), + int.parse(match.group(3)!), + ); + } +} + +String _callerFileLine() { + final stack = FlutterError.demangleStackTrace(StackTrace.current) + .toString() + .split('\n') + .map(_StackFrame.fromString) + .whereType<_StackFrame>() + .where((frame) => !frame.file.contains('logger.dart')); + return stack.isEmpty ? '' : stack.first.toString(); +} + +final class Logger { + static const _bufferSize = 1000; + static final CircularBuffer _log = CircularBuffer(_bufferSize); + static bool verbose = false; + + static List get log => List.unmodifiable(_log); + + final List tags; + + const Logger(this.tags); + + void d(String text, [StackTrace? stackTrace]) => + _logLine(LogLevel.debug, text, stackTrace); + + void e(String text, [StackTrace? stackTrace]) => + _logLine(LogLevel.error, text, stackTrace); + void i(String text, [StackTrace? stackTrace]) => + _logLine(LogLevel.info, text, stackTrace); + void logError(Error e, [String? message]) { + debugPrintStack( + stackTrace: e.stackTrace, + label: message != null ? '$tags $message ($e)' : tags.toString()); + _log.add(LogLine( + clock.now(), LogLevel.warning, tags, '$message ($e)', e.stackTrace)); + } + + void v(String text, [StackTrace? stackTrace]) => + _logLine(LogLevel.verbose, text, stackTrace); + void w(String text, [StackTrace? stackTrace]) => + _logLine(LogLevel.warning, text, stackTrace); + + void _logLine(LogLevel level, String text, [StackTrace? stackTrace]) { + if (level == LogLevel.verbose && !verbose) return; + final line = + '${_callerFileLine()}: ${level.name[0].toUpperCase()} $tags $text'; + if (stackTrace != null) { + debugPrintStack(stackTrace: stackTrace, label: line); + _log.add(LogLine(clock.now(), level, tags, text, stackTrace)); + } else { + debugPrint(line); + _log.add(LogLine(clock.now(), level, tags, text)); + } + } + + T Function(A) catching(T Function(A) f) { + return (A a) { + try { + v('Calling $f with ${a.toString().substring(0, min(20, a.toString().length))}'); + return f(a); + } catch (exn, stackTrace) { + e('Caught exception $exn', stackTrace); + rethrow; + } + }; + } +} + +enum LogLevel { verbose, debug, info, warning, error } + +final class LogLine { + final DateTime timestamp; + final LogLevel level; + final List tags; + final String message; + final StackTrace? stackTrace; + + const LogLine( + this.timestamp, + this.level, + this.tags, + this.message, [ + this.stackTrace, + ]); +} diff --git a/lib/main.dart b/lib/main.dart index 95b8f50..31748c9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,23 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:btox/btox_app.dart'; -import 'package:btox/btox_reducer.dart'; -import 'package:btox/btox_state.dart'; -import 'package:btox/db/database.dart'; -import 'package:btox/db/shared.dart'; import 'package:flutter/material.dart'; -import 'package:redux/redux.dart'; -void main() { - final Database database = constructDb(); - final store = createStore(); - runApp(BtoxApp( - database: database, - store: store, - )); -} - -Store createStore() { - return Store( - btoxReducer, - initialState: BtoxState.initial(), - ); +void main() async { + runApp(const ProviderScope(child: BtoxApp())); } diff --git a/lib/models/id.dart b/lib/models/id.dart new file mode 100644 index 0000000..46366ad --- /dev/null +++ b/lib/models/id.dart @@ -0,0 +1,47 @@ +import 'package:drift/drift.dart'; + +final class Id { + final int value; + + const Id(this.value); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Id && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() { + return 'Id<$T>($value)'; + } +} + +final class IdConverter extends TypeConverter, int> + with JsonTypeConverter2, int, int> { + const IdConverter(); + + @override + Id fromJson(int json) { + return Id(json); + } + + @override + Id fromSql(int fromDb) { + return Id(fromDb); + } + + @override + int toJson(Id value) { + return value.value; + } + + @override + int toSql(Id value) { + return value.value; + } +} diff --git a/lib/models/persistence.dart b/lib/models/persistence.dart new file mode 100644 index 0000000..65384e5 --- /dev/null +++ b/lib/models/persistence.dart @@ -0,0 +1,31 @@ +import 'package:btox/models/id.dart'; +import 'package:btox/models/profile_settings.dart'; +import 'package:drift/drift.dart'; + +class Contacts extends Table { + IntColumn get id => + integer().autoIncrement().map(const IdConverter())(); + IntColumn get profileId => + integer().references(Profiles, #id).map(const IdConverter())(); + + TextColumn get name => text().nullable()(); + TextColumn get publicKey => text()(); +} + +class Messages extends Table { + IntColumn get id => + integer().autoIncrement().map(const IdConverter())(); + IntColumn get contactId => + integer().references(Contacts, #id).map(const IdConverter())(); + + DateTimeColumn get timestamp => dateTime()(); + TextColumn get content => text()(); +} + +class Profiles extends Table { + IntColumn get id => + integer().autoIncrement().map(const IdConverter())(); + + BoolColumn get active => boolean().withDefault(const Constant(false))(); + TextColumn get settings => text().map(const ProfileSettingsConverter())(); +} diff --git a/lib/models/profile_settings.dart b/lib/models/profile_settings.dart new file mode 100644 index 0000000..6bde5cb --- /dev/null +++ b/lib/models/profile_settings.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' hide JsonKey; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profile_settings.g.dart'; +part 'profile_settings.freezed.dart'; + +@freezed +class ProfileSettings with _$ProfileSettings { + const factory ProfileSettings({ + required String nickname, + required String statusMessage, + }) = _ProfileSettings; + + factory ProfileSettings.fromJson(Map json) => + _$ProfileSettingsFromJson(json); +} + +final class ProfileSettingsConverter + extends TypeConverter + with JsonTypeConverter2> { + const ProfileSettingsConverter(); + + @override + ProfileSettings fromJson(Map json) { + return ProfileSettings.fromJson(json); + } + + @override + ProfileSettings fromSql(String fromDb) { + return fromJson(jsonDecode(fromDb)); + } + + @override + Map toJson(ProfileSettings value) { + return value.toJson(); + } + + @override + String toSql(ProfileSettings value) { + return jsonEncode(toJson(value)); + } +} diff --git a/lib/models/tox_constants.dart b/lib/models/tox_constants.dart new file mode 100644 index 0000000..c538cdb --- /dev/null +++ b/lib/models/tox_constants.dart @@ -0,0 +1,16 @@ +final class ToxConstants { + static const int publicKeySize = 32; + static const int secretKeySize = 32; + static const int conferenceIdSize = 32; + static const int nospamSize = 4; + static const int addressSize = 38; + static const int maxNameLength = 128; + static const int maxStatusMessageLength = 1007; + static const int maxFriendRequestLength = 921; + static const int maxMessageLength = 1372; + static const int maxCustomPacketSize = 1373; + static const int hashLength = 32; + static const int fileIdLength = 32; + static const int maxFilenameLength = 255; + static const int maxHostnameLength = 255; +} diff --git a/lib/add_contact_page.dart b/lib/pages/add_contact_page.dart similarity index 51% rename from lib/add_contact_page.dart rename to lib/pages/add_contact_page.dart index 08b07de..bc28013 100644 --- a/lib/add_contact_page.dart +++ b/lib/pages/add_contact_page.dart @@ -2,8 +2,9 @@ import 'package:btox/widgets/friend_request_message_field.dart'; import 'package:btox/widgets/tox_id_field.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; -final class AddContactPage extends StatefulWidget { +final class AddContactPage extends HookWidget { final String selfName; final Function(String, String) onAddContact; @@ -13,21 +14,13 @@ final class AddContactPage extends StatefulWidget { required this.onAddContact, }); - @override - State createState() => _AddContactPageState(); -} - -final class _AddContactPageState extends State { - final _formKey = GlobalKey(); - final _toxIdInputController = TextEditingController(); - final _messageInputController = TextEditingController(); - @override Widget build(BuildContext context) { - if (_messageInputController.text.isEmpty) { - _messageInputController.text = AppLocalizations.of(context)! - .defaultAddContactMessage(widget.selfName); - } + final formKey = useMemoized(() => GlobalKey()); + final toxIdInputController = useTextEditingController(); + final messageInputController = useTextEditingController( + text: AppLocalizations.of(context)!.defaultAddContactMessage(selfName), + ); return Scaffold( appBar: AppBar( @@ -35,20 +28,30 @@ final class _AddContactPageState extends State { ), body: SingleChildScrollView( child: Form( - key: _formKey, + key: formKey, child: Column( children: [ - ToxIdField(controller: _toxIdInputController), + ToxIdField(controller: toxIdInputController), FriendRequestMessageField( - controller: _messageInputController, - onEditingComplete: () => _onAddContact(_formKey.currentState!), + controller: messageInputController, + onEditingComplete: () => _onAddContact( + context, + formKey.currentState!, + toxIdInputController.text, + messageInputController.text, + ), ), Align( alignment: Alignment.topRight, child: Padding( padding: const EdgeInsets.all(16), child: ElevatedButton( - onPressed: () => _onAddContact(_formKey.currentState!), + onPressed: () => _onAddContact( + context, + formKey.currentState!, + toxIdInputController.text, + messageInputController.text, + ), child: Text(AppLocalizations.of(context)!.add), ), ), @@ -60,10 +63,10 @@ final class _AddContactPageState extends State { ); } - void _onAddContact(FormState form) { + void _onAddContact( + BuildContext context, FormState form, String toxId, String message) { if (form.validate()) { - widget.onAddContact( - _toxIdInputController.text, _messageInputController.text); + onAddContact(toxId, message); Navigator.pop(context); } } diff --git a/lib/chat_page.dart b/lib/pages/chat_page.dart similarity index 69% rename from lib/chat_page.dart rename to lib/pages/chat_page.dart index 0736070..3633523 100644 --- a/lib/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,8 +1,9 @@ import 'package:btox/db/database.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; -final class ChatPage extends StatefulWidget { +final class ChatPage extends HookWidget { final Stream contact; final Stream> messages; final void Function(String message) onSendMessage; @@ -14,18 +15,13 @@ final class ChatPage extends StatefulWidget { required this.onSendMessage, }); - @override - State createState() => _ChatPageState(); -} - -final class _ChatPageState extends State { - final _messageInputFocus = FocusNode(); - final _messageInputController = TextEditingController(); - @override Widget build(BuildContext context) { + final messageInputController = useTextEditingController(); + final messageInputFocus = useFocusNode(); + return StreamBuilder( - stream: widget.contact, + stream: contact, builder: (context, snapshot) => Scaffold( appBar: AppBar( title: Text(snapshot.data?.name ?? @@ -35,7 +31,7 @@ final class _ChatPageState extends State { children: [ Expanded( child: StreamBuilder>( - stream: widget.messages, + stream: messages, builder: ((context, snapshot) { final messages = snapshot.data ?? []; return ListView.builder( @@ -60,13 +56,19 @@ final class _ChatPageState extends State { border: const UnderlineInputBorder(), labelText: AppLocalizations.of(context)!.messageInput, suffixIcon: IconButton( - onPressed: () => _onSendMessage(), + onPressed: () => _onSendMessage( + messageInputController, + messageInputFocus, + ), icon: const Icon(Icons.send), ), ), - onEditingComplete: () => _onSendMessage(), - controller: _messageInputController, - focusNode: _messageInputFocus, + onEditingComplete: () => _onSendMessage( + messageInputController, + messageInputFocus, + ), + controller: messageInputController, + focusNode: messageInputFocus, textInputAction: TextInputAction.send, autofocus: true, ), @@ -77,9 +79,12 @@ final class _ChatPageState extends State { ); } - void _onSendMessage() { - widget.onSendMessage(_messageInputController.text); - _messageInputController.clear(); - _messageInputFocus.requestFocus(); + void _onSendMessage( + TextEditingController messageInputController, + FocusNode messageInputFocus, + ) { + onSendMessage(messageInputController.text); + messageInputController.clear(); + messageInputFocus.requestFocus(); } } diff --git a/lib/contact_list_page.dart b/lib/pages/contact_list_page.dart similarity index 57% rename from lib/contact_list_page.dart rename to lib/pages/contact_list_page.dart index cf1dfe6..204eb35 100644 --- a/lib/contact_list_page.dart +++ b/lib/pages/contact_list_page.dart @@ -1,42 +1,25 @@ -import 'package:btox/add_contact_page.dart'; -import 'package:btox/btox_state.dart'; -import 'package:btox/chat_page.dart'; import 'package:btox/db/database.dart'; -import 'package:btox/profile.dart'; -import 'package:btox/settings.dart'; +import 'package:btox/logger.dart'; +import 'package:btox/pages/add_contact_page.dart'; +import 'package:btox/pages/chat_page.dart'; +import 'package:btox/pages/user_profile_page.dart'; +import 'package:btox/pages/settings_page.dart'; +import 'package:btox/widgets/contact_list_item.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_redux/flutter_redux.dart'; -final class ContactListItem extends StatelessWidget { - final Contact contact; - final Function(Contact) onTap; - - const ContactListItem({ - super.key, - required this.contact, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text( - contact.name ?? AppLocalizations.of(context)!.defaultContactName), - subtitle: Text(contact.publicKey, overflow: TextOverflow.ellipsis), - onTap: () => onTap(contact), - ); - } -} +const _logger = Logger(['ContactListPage']); final class ContactListPage extends StatelessWidget { final Database database; + final Profile profile; const ContactListPage({ super.key, required this.database, + required this.profile, }); @override @@ -51,33 +34,22 @@ final class ContactListPage extends StatelessWidget { color: Colors.blue, ), child: ListTile( - title: StoreConnector( - converter: (store) => store.state.nickname, - builder: (context, nickname) { - return Text( - nickname, - style: const TextStyle( - fontSize: 16.0, - color: Colors.white, - fontWeight: FontWeight.w400, - ), - ); - }, - ), - subtitle: StoreConnector( - converter: (store) => store.state.statusMessage, - builder: (context, String statusMessage) { - return Text( - statusMessage, - style: const TextStyle( - fontSize: 12.0, - color: Colors.white, - fontWeight: FontWeight.w100, - ), - ); - }, - ), - ), + title: Text( + profile.settings.nickname, + style: const TextStyle( + fontSize: 16.0, + color: Colors.white, + fontWeight: FontWeight.w400, + ), + ), + subtitle: Text( + profile.settings.statusMessage, + style: const TextStyle( + fontSize: 12.0, + color: Colors.white, + fontWeight: FontWeight.w100, + ), + )), ), ListTile( leading: const Icon(Icons.person), @@ -87,12 +59,24 @@ final class ContactListPage extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => StoreConnector( - converter: (store) => store.state, - builder: (context, state) => UserProfilePage( - state: state, - store: StoreProvider.of(context), - ), + builder: (context) => StreamBuilder( + stream: database.watchProfile(profile.id), + builder: (context, snapshot) { + final profile = snapshot.data; + if (profile == null) { + return const Center( + child: CircularProgressIndicator(), + ); + } + return UserProfilePage( + profile: profile, + onUpdateProfile: (settings) async { + await database.updateProfileSettings( + profile.id, settings); + _logger.d('Updated profile settings'); + }, + ); + }, ), ), ); @@ -106,11 +90,23 @@ final class ContactListPage extends StatelessWidget { Navigator.push( context, MaterialPageRoute( - builder: (context) => const SettingsPage(), + builder: (context) => SettingsPage( + database: database, + profile: profile, + ), ), ); }, ), + ListTile( + leading: const Icon(Icons.logout), + title: Text(AppLocalizations.of(context)!.logout), + onTap: () async { + Navigator.pop(context); // close drawer before navigating away + _logger.d('Logging out'); + await database.deactivateProfiles(); + }, + ), if (defaultTargetPlatform == TargetPlatform.android) ListTile( leading: const Icon(Icons.close), @@ -137,7 +133,7 @@ final class ContactListPage extends StatelessWidget { title: Text(AppLocalizations.of(context)!.title), ), body: StreamBuilder>( - stream: database.watchContacts(), + stream: database.watchContactsFor(profile.id), builder: ((context, snapshot) { final contacts = snapshot.data ?? []; @@ -147,6 +143,7 @@ final class ContactListPage extends StatelessWidget { return ContactListItem( contact: contacts[index], onTap: (Contact contact) { + _logger.d('Opening chat with contact: ${contact.id}'); Navigator.push( context, MaterialPageRoute( @@ -174,8 +171,16 @@ final class ContactListPage extends StatelessWidget { context, MaterialPageRoute( builder: (context) => AddContactPage( - onAddContact: _onAddContact, - selfName: StoreProvider.of(context).state.nickname, + onAddContact: (toxID, message) async { + final id = await database.addContact( + ContactsCompanion.insert( + profileId: profile.id, + publicKey: toxID.substring(0, toxID.length - 12), + ), + ); + _logger.d('Added contact: $id'); + }, + selfName: profile.settings.nickname, ), ), ), @@ -184,12 +189,4 @@ final class ContactListPage extends StatelessWidget { ), ); } - - void _onAddContact(String toxID, String message) { - database.addContact( - ContactsCompanion.insert( - publicKey: toxID.substring(0, toxID.length - 12), - ), - ); - } } diff --git a/lib/pages/create_profile_page.dart b/lib/pages/create_profile_page.dart new file mode 100644 index 0000000..c8ddc5d --- /dev/null +++ b/lib/pages/create_profile_page.dart @@ -0,0 +1,74 @@ +import 'package:btox/db/database.dart'; +import 'package:btox/logger.dart'; +import 'package:btox/models/id.dart'; +import 'package:btox/models/persistence.dart'; +import 'package:btox/models/profile_settings.dart'; +import 'package:btox/providers/database.dart'; +import 'package:btox/widgets/nickname_field.dart'; +import 'package:btox/widgets/status_message_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const _logger = Logger(['CreateProfilePage']); + +final class CreateProfilePage extends HookConsumerWidget { + final Function(Id)? onProfileCreated; + + const CreateProfilePage({ + super.key, + this.onProfileCreated, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nicknameController = useTextEditingController( + text: 'Yanciman', + ); + final statusMessageController = useTextEditingController( + text: 'Producing works of art in Kannywood', + ); + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.newProfile), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + NicknameField( + controller: nicknameController, + ), + const Padding( + padding: EdgeInsets.all(8), + ), + StatusMessageField( + controller: statusMessageController, + ), + const Padding( + padding: EdgeInsets.all(8), + ), + ElevatedButton( + onPressed: () async { + _logger.d('Creating new profile'); + final db = await ref.read(databaseProvider.future); + final id = await db.addProfile(ProfilesCompanion( + active: const Value(true), + settings: Value(ProfileSettings( + nickname: nicknameController.text, + statusMessage: statusMessageController.text, + )), + )); + _logger.d('Created new profile with ID $id'); + onProfileCreated?.call(id); + }, + child: Text(AppLocalizations.of(context)!.create), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/select_profile_page.dart b/lib/pages/select_profile_page.dart new file mode 100644 index 0000000..2403a36 --- /dev/null +++ b/lib/pages/select_profile_page.dart @@ -0,0 +1,56 @@ +import 'package:btox/db/database.dart'; +import 'package:btox/logger.dart'; +import 'package:btox/pages/create_profile_page.dart'; +import 'package:btox/providers/database.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const _logger = Logger(['SelectProfilePage']); + +final class SelectProfilePage extends ConsumerWidget { + final List profiles; + + const SelectProfilePage({ + super.key, + required this.profiles, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text('Select Profile'), + ), + body: ListView.builder( + itemCount: profiles.length, + itemBuilder: (context, index) { + final profile = profiles[index]; + return ListTile( + title: Text(profile.settings.nickname), + subtitle: Text(profile.settings.statusMessage), + onTap: () async { + _logger.d('Selecting profile: ${profile.id}'); + final db = await ref.read(databaseProvider.future); + await db.activateProfile(profile.id); + }, + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: () async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CreateProfilePage( + onProfileCreated: (profileId) { + Navigator.pop(context); + }, + ), + ), + ); + }, + child: const Icon(Icons.add), + ), + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart new file mode 100644 index 0000000..20dc91b --- /dev/null +++ b/lib/pages/settings_page.dart @@ -0,0 +1,74 @@ +import 'package:btox/db/database.dart'; +import 'package:btox/logger.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +const _logger = Logger(['SettingsPage']); + +final class SettingsPage extends StatelessWidget { + final Database database; + final Profile profile; + + const SettingsPage({ + super.key, + required this.database, + required this.profile, + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.menuSettings), + ), + body: ListView( + children: [ + ListTile( + title: Text(AppLocalizations.of(context)!.deleteProfile), + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(AppLocalizations.of(context)!.deleteProfile), + content: Text( + AppLocalizations.of(context)!.deleteProfileMessage( + profile.settings.nickname, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(AppLocalizations.of(context)!.cancel), + ), + TextButton( + onPressed: () async { + _logger.i('Deleting profile ${profile.id}'); + Navigator.pop(context); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.profileDeleted( + profile.settings.nickname, + ), + ), + ), + ); + + Navigator.pop(context); + + await database.deleteProfile(profile.id); + _logger.i('Profile ${profile.id} deleted'); + }, + child: Text(AppLocalizations.of(context)!.delete), + ), + ], + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/user_profile_page.dart b/lib/pages/user_profile_page.dart new file mode 100644 index 0000000..1239807 --- /dev/null +++ b/lib/pages/user_profile_page.dart @@ -0,0 +1,85 @@ +import 'package:btox/db/database.dart'; +import 'package:btox/models/profile_settings.dart'; +import 'package:btox/widgets/nickname_field.dart'; +import 'package:btox/widgets/status_message_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +final class UserProfilePage extends HookWidget { + final Profile profile; + final void Function(ProfileSettings) onUpdateProfile; + + const UserProfilePage({ + super.key, + required this.profile, + required this.onUpdateProfile, + }); + + @override + Widget build(BuildContext context) { + final formKey = useMemoized(() => GlobalKey()); + final nickInputController = useTextEditingController( + text: profile.settings.nickname, + ); + final statusMessageInputController = useTextEditingController( + text: profile.settings.statusMessage, + ); + final applyButtonPressed = useState(false); + + void onValidate() { + if (!formKey.currentState!.validate()) { + return; + } + + if (profile.settings.nickname != nickInputController.text || + profile.settings.statusMessage != statusMessageInputController.text) { + applyButtonPressed.value = true; + onUpdateProfile( + profile.settings.copyWith( + nickname: nickInputController.text, + statusMessage: statusMessageInputController.text, + ), + ); + } + } + + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.menuProfile), + ), + body: Form( + key: formKey, + child: Column( + children: [ + const Padding( + padding: EdgeInsets.all(8), + ), + NicknameField( + controller: nickInputController, + onChanged: (value) => applyButtonPressed.value = false, + ), + const Padding( + padding: EdgeInsets.all(8), + ), + StatusMessageField( + controller: statusMessageInputController, + onChanged: (value) => applyButtonPressed.value = false, + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + applyButtonPressed.value ? Colors.green : Colors.blue, + foregroundColor: Colors.white, + ), + onPressed: () => onValidate(), + child: applyButtonPressed.value + ? Text(AppLocalizations.of(context)!.applied) + : Text(AppLocalizations.of(context)!.applyChanges), + ), + ], + ), + ), + ); + } +} diff --git a/lib/profile.dart b/lib/profile.dart deleted file mode 100644 index 5e1d909..0000000 --- a/lib/profile.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:btox/btox_actions.dart'; -import 'package:btox/btox_state.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:redux/redux.dart'; - -final class UserProfilePage extends StatefulWidget { - final BtoxState state; - final Store store; - - const UserProfilePage({super.key, required this.state, required this.store}); - - @override - State createState() => _UserProfilePageState(); -} - -final class _UserProfilePageState extends State { - final _formKey = GlobalKey(); - final _statusMessageInputController = TextEditingController(); - final _nickInputController = TextEditingController(); - bool _applyButtonPressed = false; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.menuProfile), - ), - body: Form( - key: _formKey, - child: Column( - children: [ - const Padding( - padding: EdgeInsets.all(8), - ), - Text( - AppLocalizations.of(context)!.profileTextFieldNick, - style: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: TextFormField( - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - value ??= ''; - if (value.isEmpty || value.length > 32) { - return AppLocalizations.of(context)!.nickLengthError; - } - - return null; - }, - controller: _nickInputController, - textInputAction: TextInputAction.next, - onChanged: (value) { - _setApplyButtonPressed(false); - }, - ), - ), - const Padding( - padding: EdgeInsets.all(8), - ), - Text( - AppLocalizations.of(context)!.profileTextFieldUserStatusMessage, - style: TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.bold, - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: TextFormField( - autovalidateMode: AutovalidateMode.onUserInteraction, - validator: (value) { - value ??= ''; - if (value.length > 256) { - return AppLocalizations.of(context)! - .statusMessageLengthError; - } - - return null; - }, - controller: _statusMessageInputController, - textInputAction: TextInputAction.next, - onChanged: (value) { - _setApplyButtonPressed(false); - }, - ), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: - _applyButtonPressed ? Colors.green : Colors.blue, - foregroundColor: Colors.white, - ), - onPressed: () => _onValidate(), - child: _applyButtonPressed - ? Text(AppLocalizations.of(context)!.applied) - : Text(AppLocalizations.of(context)!.applyChanges), - ), - ], - ), - ), - ); - } - - @override - initState() { - super.initState(); - _nickInputController.text = widget.state.nickname; - _statusMessageInputController.text = widget.state.statusMessage; - } - - _onValidate() { - if (!_formKey.currentState!.validate()) { - return; - } - - if (widget.state.nickname != _nickInputController.text || - widget.state.statusMessage != _statusMessageInputController.text) { - _setApplyButtonPressed(true); - widget.store - .dispatch(BtoxUpdateNicknameAction(_nickInputController.text)); - widget.store.dispatch( - BtoxUpdateStatusMessageAction(_statusMessageInputController.text)); - } - } - - void _setApplyButtonPressed(bool pressed) { - setState(() { - _applyButtonPressed = pressed; - }); - } -} diff --git a/lib/providers/database.dart b/lib/providers/database.dart new file mode 100644 index 0000000..4ba993e --- /dev/null +++ b/lib/providers/database.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:btox/db/database.dart'; +import 'package:btox/db/shared.dart'; +import 'package:btox/logger.dart'; + +part 'database.g.dart'; + +const _logger = Logger(['DatabaseProvider']); + +@riverpod +Future database(Ref ref) async { + _logger.d('Opening database'); + final db = await constructDb(); + ref.onDispose(() => db.close()); + _logger.d('Database opened: schema version ${db.schemaVersion}'); + return db; +} diff --git a/lib/settings.dart b/lib/settings.dart deleted file mode 100644 index b46a721..0000000 --- a/lib/settings.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -final class SettingsPage extends StatelessWidget { - const SettingsPage({super.key}); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.menuSettings), - ), - ); - } -} diff --git a/lib/widgets/contact_list_item.dart b/lib/widgets/contact_list_item.dart new file mode 100644 index 0000000..956e18f --- /dev/null +++ b/lib/widgets/contact_list_item.dart @@ -0,0 +1,24 @@ +import 'package:btox/db/database.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +final class ContactListItem extends StatelessWidget { + final Contact contact; + final Function(Contact) onTap; + + const ContactListItem({ + super.key, + required this.contact, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text( + contact.name ?? AppLocalizations.of(context)!.defaultContactName), + subtitle: Text(contact.publicKey, overflow: TextOverflow.ellipsis), + onTap: () => onTap(contact), + ); + } +} diff --git a/lib/widgets/friend_request_message_field.dart b/lib/widgets/friend_request_message_field.dart index 7c18efb..2a05ce4 100644 --- a/lib/widgets/friend_request_message_field.dart +++ b/lib/widgets/friend_request_message_field.dart @@ -1,3 +1,4 @@ +import 'package:btox/models/tox_constants.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -22,6 +23,12 @@ final class FriendRequestMessageField extends StatelessWidget { if (value == null || value.isEmpty) { return AppLocalizations.of(context)!.messageEmptyError; } + if (value.length > ToxConstants.maxFriendRequestLength) { + return AppLocalizations.of(context)!.addContactMessageLengthError( + ToxConstants.maxFriendRequestLength, + value.length, + ); + } return null; }, decoration: InputDecoration( diff --git a/lib/widgets/nickname_field.dart b/lib/widgets/nickname_field.dart new file mode 100644 index 0000000..06430c7 --- /dev/null +++ b/lib/widgets/nickname_field.dart @@ -0,0 +1,46 @@ +import 'package:btox/models/tox_constants.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +final class NicknameField extends StatelessWidget { + final TextEditingController controller; + final void Function(String)? onChanged; + + const NicknameField({ + super.key, + required this.controller, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column(children: [ + Text( + AppLocalizations.of(context)!.profileTextFieldNick, + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + value ??= ''; + if (value.isEmpty || value.length > ToxConstants.maxNameLength) { + return AppLocalizations.of(context)!.nickLengthError( + ToxConstants.maxNameLength, + ); + } + + return null; + }, + controller: controller, + textInputAction: TextInputAction.next, + onChanged: onChanged, + ), + ), + ]); + } +} diff --git a/lib/widgets/status_message_field.dart b/lib/widgets/status_message_field.dart new file mode 100644 index 0000000..54888f9 --- /dev/null +++ b/lib/widgets/status_message_field.dart @@ -0,0 +1,45 @@ +import 'package:btox/models/tox_constants.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +final class StatusMessageField extends StatelessWidget { + final TextEditingController controller; + final void Function(String)? onChanged; + + const StatusMessageField({ + super.key, + required this.controller, + this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Column(children: [ + Text( + AppLocalizations.of(context)!.profileTextFieldUserStatusMessage, + style: TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextFormField( + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (value) { + value ??= ''; + if (value.length > ToxConstants.maxStatusMessageLength) { + return AppLocalizations.of(context)!.statusMessageLengthError( + ToxConstants.maxStatusMessageLength); + } + + return null; + }, + controller: controller, + textInputAction: TextInputAction.next, + onChanged: onChanged, + ), + ), + ]); + } +} diff --git a/lib/widgets/tox_id_field.dart b/lib/widgets/tox_id_field.dart index 998a573..52ebe57 100644 --- a/lib/widgets/tox_id_field.dart +++ b/lib/widgets/tox_id_field.dart @@ -1,3 +1,4 @@ +import 'package:btox/models/tox_constants.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -19,8 +20,10 @@ final class ToxIdField extends StatelessWidget { autovalidateMode: AutovalidateMode.onUserInteraction, validator: (value) { value ??= ''; - if (value.length != 76) { - return '${AppLocalizations.of(context)!.toxIdLengthError} (${value.length}/76)'; + if (value.length != ToxConstants.addressSize * 2) { + final msg = AppLocalizations.of(context)! + .toxIdLengthError(ToxConstants.addressSize * 2); + return '$msg (${value.length}/76)'; } return null; }, diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 5d55939..5bbaea1 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -3,21 +3,21 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (3.47.2): - - sqlite3/common (= 3.47.2) - - sqlite3/common (3.47.2) - - sqlite3/dbstatvtab (3.47.2): + - sqlite3 (3.48.0): + - sqlite3/common (= 3.48.0) + - sqlite3/common (3.48.0) + - sqlite3/dbstatvtab (3.48.0): - sqlite3/common - - sqlite3/fts5 (3.47.2): + - sqlite3/fts5 (3.48.0): - sqlite3/common - - sqlite3/perf-threadsafe (3.47.2): + - sqlite3/perf-threadsafe (3.48.0): - sqlite3/common - - sqlite3/rtree (3.47.2): + - sqlite3/rtree (3.48.0): - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter - FlutterMacOS - - sqlite3 (~> 3.47.2) + - sqlite3 (~> 3.48.0) - sqlite3/dbstatvtab - sqlite3/fts5 - sqlite3/perf-threadsafe @@ -43,8 +43,8 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - sqlite3: 7559e33dae4c78538df563795af3a86fc887ee71 - sqlite3_flutter_libs: 5235ce0546528db87927a3ef1baff8b7d5107f0e + sqlite3: 3da10a59910c809fb584a93aa46a3f05b785e12e + sqlite3_flutter_libs: c26d86af4ad88f1465dc4e07e6dc6931eef228e4 PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 diff --git a/netlify.toml b/netlify.toml index b32955d..2545c88 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,2 +1,2 @@ [build] - command = "if cd flutter; then git fetch && git reset --hard 3.24.4 && cd ..; else git clone https://github.com/flutter/flutter.git --branch 3.24.4; fi && export PATH=flutter/bin:$PATH && flutter config --enable-web && ./tools/prepare-web && flutter build web --release" +command = "if cd flutter; then git fetch && git reset --hard 3.27.3 && cd ..; else git clone https://github.com/flutter/flutter.git --branch 3.27.3; fi && export PATH=flutter/bin:$PATH && flutter config --enable-web && ./tools/prepare-web && flutter build web --release" diff --git a/pubspec.lock b/pubspec.lock index 32e4fd6..aeca639 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" + sha256: "88399e291da5f7e889359681a8f64b18c5123e03576b01f32a6a276611e511c3" url: "https://pub.dev" source: hosted - version: "76.0.0" + version: "78.0.0" _macros: dependency: transitive description: dart @@ -18,10 +18,18 @@ packages: dependency: "direct dev" description: name: analyzer - sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" + sha256: "62899ef43d0b962b056ed2ebac6b47ec76ffd003d5f7c4e4dc870afe63188e33" url: "https://pub.dev" source: hosted - version: "6.11.0" + version: "7.1.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "1d460d14e3c2ae36dc2b32cef847c4479198cf87704f63c3c3c8150ee50c3916" + url: "https://pub.dev" + source: hosted + version: "0.12.0" args: dependency: transitive description: @@ -134,6 +142,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + circular_buffer: + dependency: "direct main" + description: + name: circular_buffer + sha256: b3a315fef3fee7fe58879643fc8ce21c7c2449d01c1a8a396dc9e24687f335c4 + url: "https://pub.dev" + source: hosted + version: "0.12.0" cli_util: dependency: transitive description: @@ -143,7 +167,7 @@ packages: source: hosted version: "0.4.2" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf @@ -190,6 +214,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "6d509673c4dd0baa90e60dc8366bc2acc6690f16a7d44bfae31294d82c5d2a62" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "8cc525c7b160eb47bb1ded8b2633c0f8b907930eb986ac577aded87cdd2835fe" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "6dcee8a017181941c51a110da7e267c1d104dc74bec8862eeb8c85b5c8759a9e" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "14df0760dfa81b7b0c398c876045f4e4a343eb2c9d200c66163671dd3e337c1b" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.1.0" dart_style: dependency: transitive description: @@ -202,18 +258,18 @@ packages: dependency: "direct main" description: name: drift - sha256: cc593c8acaccaf4a5750fac034e06289f572038a38391d4343739789591e6b01 + sha256: "76f23535e19a9f2be92f954e74d8802e96f526e5195d7408c1a20f6659043941" url: "https://pub.dev" source: hosted - version: "2.23.0" + version: "2.24.0" drift_dev: dependency: "direct dev" description: name: drift_dev - sha256: fde4fcb5776c2048ecdf672de396e3fdad8e93bf5148b69dd401cf6767a16e0d + sha256: d1d90b0d55b22de412b77186f3bf3179a4b7e2acc4c8fb3a7aaf28a01abc194b url: "https://pub.dev" source: hosted - version: "2.23.0" + version: "2.24.0" fake_async: dependency: transitive description: @@ -251,6 +307,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_hooks: + dependency: "direct main" + description: + name: flutter_hooks + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 + url: "https://pub.dev" + source: hosted + version: "0.20.5" flutter_lints: dependency: "direct dev" description: @@ -264,19 +328,35 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_redux: + flutter_riverpod: dependency: "direct main" description: - name: flutter_redux - sha256: "3b20be9e08d0038e1452fbfa1fdb1ea0a7c3738c997734530b3c6d0bb5fcdbdc" + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" url: "https://pub.dev" source: hosted - version: "0.10.0" + version: "2.6.1" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + freezed: + dependency: "direct dev" + description: + name: freezed + sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" + url: "https://pub.dev" + source: hosted + version: "2.5.8" + freezed_annotation: + dependency: "direct main" + description: + name: freezed_annotation + sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 + url: "https://pub.dev" + source: hosted + version: "2.4.4" frontend_server_client: dependency: transitive description: @@ -289,10 +369,10 @@ packages: dependency: transitive description: name: glob - sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" graphs: dependency: transitive description: @@ -301,6 +381,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks_riverpod: + dependency: "direct main" + description: + name: hooks_riverpod + sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b + url: "https://pub.dev" + source: hosted + version: "4.3.0" http_multi_server: dependency: transitive description: @@ -313,10 +409,10 @@ packages: dependency: transitive description: name: http_parser - sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360" + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.2" intl: dependency: "direct main" description: @@ -342,13 +438,21 @@ packages: source: hosted version: "0.7.1" json_annotation: - dependency: transitive + dependency: "direct main" description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" url: "https://pub.dev" source: hosted version: "4.9.0" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: b0a98230538fe5d0b60a22fb6bf1b6cb03471b53e3324ff6069c591679dd59c9 + url: "https://pub.dev" + source: hosted + version: "6.9.3" leak_tracker: dependency: transitive description: @@ -529,10 +633,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: "81876843eb50dc2e1e5b151792c9a985c5ed2536914115ed04e9c8528f6647b0" + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" recase: dependency: transitive description: @@ -541,14 +645,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - redux: + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" + url: "https://pub.dev" + source: hosted + version: "0.5.9" + riverpod_annotation: dependency: "direct main" description: - name: redux - sha256: "1e86ed5b1a9a717922d0a0ca41f9bf49c1a587d50050e9426fc65b14e85ec4d7" + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" + url: "https://pub.dev" + source: hosted + version: "2.6.4" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: b05408412b0f75dec954e032c855bc28349eeed2d2187f94519e1ddfdf8b3693 + url: "https://pub.dev" + source: hosted + version: "2.6.4" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shelf: dependency: transitive description: @@ -578,6 +722,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" + url: "https://pub.dev" + source: hosted + version: "1.3.5" source_span: dependency: transitive description: @@ -586,30 +738,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqlite3: dependency: transitive description: name: sqlite3 - sha256: cb7f4e9dc1b52b1fa350f7b3d41c662e75fc3d399555fa4e5efcf267e9a4fbb5 + sha256: "35d3726fe18ab1463403a5cc8d97dbc81f2a0b08082e8173851363fcc97b6627" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.7.2" sqlite3_flutter_libs: dependency: "direct main" description: name: sqlite3_flutter_libs - sha256: "73016db8419f019e807b7a5e5fbf2a7bd45c165fed403b8e7681230f3a102785" + sha256: "50a7e3f294c741d3142eed0ff228e38498334e11e0ccb9d73e0496e005949e44" url: "https://pub.dev" source: hosted - version: "0.5.28" + version: "0.5.29" sqlparser: dependency: transitive description: name: sqlparser - sha256: "4cad4b2c5f63dc9ea1a8dcffb58cf762322bea5dd8836870164a65e913bdae41" + sha256: "27dd0a9f0c02e22ac0eb42a23df9ea079ce69b52bb4a3b478d64e0ef34a263ee" url: "https://pub.dev" source: hosted - version: "0.40.0" + version: "0.41.0" stack_trace: dependency: transitive description: @@ -618,6 +778,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.0" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" stream_channel: dependency: transitive description: @@ -674,6 +842,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff + url: "https://pub.dev" + source: hosted + version: "4.5.1" vector_math: dependency: transitive description: @@ -718,10 +894,10 @@ packages: dependency: transitive description: name: web_socket_channel - sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 20470c5..8d21cec 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 +version: 0.0.1 environment: sdk: ">=3.5.0 <4.0.0" @@ -29,27 +29,37 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter + circular_buffer: ^0.12.0 + clock: ^1.1.1 cupertino_icons: ^1.0.2 - drift: ^2.21.0 - sqlite3_flutter_libs: ^0.5.26 + flutter_hooks: ^0.20.5 + flutter_riverpod: ^2.6.1 + freezed_annotation: ^2.4.4 + hooks_riverpod: ^2.6.1 + intl: ^0.19.0 + json_annotation: ^4.9.0 path_provider: ^2.1.5 path: ^1.9.0 - redux: ^5.0.0 - flutter_redux: ^0.10.0 - intl: any - flutter_localizations: - sdk: flutter + riverpod_annotation: ^2.6.1 + sqlite3_flutter_libs: ^0.5.26 dev_dependencies: flutter_test: sdk: flutter - analyzer: ^6.7.0 + analyzer: ^7.1.0 build_runner: ^2.1.7 + custom_lint: ^0.7.0 drift_dev: ^2.21.2 flutter_lints: ^5.0.0 + freezed: ^2.5.7 + json_serializable: ^6.8.0 + riverpod_generator: ^2.6.2 + riverpod_lint: ^2.6.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..368bdc2 --- /dev/null +++ b/test/.gitignore @@ -0,0 +1 @@ +/failures/ diff --git a/test/btox_app.png b/test/btox_app.png new file mode 100644 index 0000000000000000000000000000000000000000..7a727de4900b6bff8aa872b6becd6babba40dad7 GIT binary patch literal 27859 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 literal 0 HcmV?d00001 diff --git a/test/widget_test.dart b/test/widget_test.dart index 19e5e86..c57c6ef 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,6 +1,8 @@ -import 'package:btox/main.dart'; +import 'package:btox/models/profile_settings.dart'; +import 'package:btox/providers/database.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:btox/btox_app.dart'; @@ -15,7 +17,30 @@ void main() { String.fromCharCodes(Iterable.generate(76, (_) => '0'.codeUnits.first)); testWidgets('Add contact adds a contact', (WidgetTester tester) async { Database db = Database(NativeDatabase.memory()); - await tester.pumpWidget(BtoxApp(database: db, store: createStore())); + final profileId = await db.addProfile( + ProfilesCompanion.insert( + active: Value(true), + settings: ProfileSettings( + nickname: 'Yeetman', + statusMessage: 'Yeeting everyone', + ), + ), + ); + + expect(profileId.value, 1); + + await tester.pumpWidget( + ProviderScope( + overrides: [databaseProvider.overrideWith((ref) => db)], + child: const BtoxApp(), + ), + ); + + // Wait for the database to be loaded. + await tester.pumpAndSettle(); + + // Take a screenshot. + await expectLater(find.byType(BtoxApp), matchesGoldenFile('btox_app.png')); // Check that no contact with all 0s for the public key exists. expect(find.textContaining('00000000'), findsNothing); diff --git a/tools/prepare-web b/tools/prepare-web index a31f4f8..5c441e1 100755 --- a/tools/prepare-web +++ b/tools/prepare-web @@ -5,9 +5,9 @@ set -eux ./tools/prepare # SQLite wasm/js. -SQLITE_VERSION=0.5.26 -curl -L "https://github.com/simolus3/sqlite3.dart/releases/download/sqlite3_flutter_libs-$SQLITE_VERSION/sqlite3.wasm" >web/sqlite3.wasm +SQLITE_VERSION=2.6.1 +curl -L "https://github.com/simolus3/sqlite3.dart/releases/download/sqlite3-$SQLITE_VERSION/sqlite3.wasm" -o web/sqlite3.wasm # Drift worker. -DRIFT_VERSION=2.21.0 -curl -L "https://github.com/simolus3/drift/releases/download/drift-$DRIFT_VERSION/drift_worker.js" >web/drift_worker.js +DRIFT_VERSION=2.23.1 +curl -L "https://github.com/simolus3/drift/releases/download/drift-$DRIFT_VERSION/drift_worker.js" -o web/drift_worker.js