diff --git a/docs/designs/settings.html b/docs/designs/settings.html index 22cfa9c..b4db81a 100644 --- a/docs/designs/settings.html +++ b/docs/designs/settings.html @@ -248,7 +248,14 @@
- + @@ -261,7 +268,14 @@
- + @@ -274,7 +288,14 @@
- + @@ -287,7 +308,14 @@
- + @@ -685,7 +713,6 @@ const mode = localStorage.getItem("appearance-mode") || "dark"; const palette = localStorage.getItem("palette") || "oxocarbon"; - // Restore mode document.querySelectorAll(".segment-btn").forEach((el) => el.classList.remove("active")); const modeBtn = document.querySelector(`.segment-btn[data-mode="${mode}"]`); if (modeBtn) modeBtn.classList.add("active"); @@ -697,7 +724,6 @@ applyVariant(mode); } - // Restore palette document.querySelectorAll(".theme-palette-row").forEach((el) => el.classList.remove("active")); const paletteRow = document.querySelector(`.theme-palette-row[data-palette="${palette}"]`); if (paletteRow) paletteRow.classList.add("active"); diff --git a/docs/tasks/phase-2.md b/docs/tasks/phase-2.md index 19b6405..ac3c5ff 100644 --- a/docs/tasks/phase-2.md +++ b/docs/tasks/phase-2.md @@ -27,14 +27,14 @@ ## M6 — Search -- [ ] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors) -- [ ] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted` -- [ ] Post search via `searchPosts` with paginated results -- [ ] Actor search via `searchActors` with paginated results -- [ ] Typeahead autocomplete via `searchActorsTypeahead` -- [ ] Drift migration: add `search_history` table (query, type, searched_at, account_did) -- [ ] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account -- [ ] Search with `@` should autocomplete with avatars + handles (debounced) +- [x] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors) +- [x] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted` +- [x] Post search via `searchPosts` with paginated results +- [x] Actor search via `searchActors` with paginated results +- [x] Typeahead autocomplete via `searchActorsTypeahead` +- [x] Drift migration: add `search_history` table (query, type, searched_at, account_did) +- [x] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account +- [x] Search with `@` should autocomplete with avatars + handles (debounced) ## M7 — Dev Tools (PDS Explorer) diff --git a/lib/core/database/app_database.dart b/lib/core/database/app_database.dart index 724db56..a7415fa 100644 --- a/lib/core/database/app_database.dart +++ b/lib/core/database/app_database.dart @@ -6,12 +6,12 @@ import 'package:lazurite/core/database/tables.dart'; part 'app_database.g.dart'; -@DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings, SavedFeeds]) +@DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings, SavedFeeds, SearchHistory]) class AppDatabase extends _$AppDatabase { AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -29,6 +29,9 @@ class AppDatabase extends _$AppDatabase { if (from < 3) { await migrator.createTable(savedFeeds); } + if (from < 4) { + await migrator.createTable(searchHistory); + } }, ); @@ -140,4 +143,37 @@ class AppDatabase extends _$AppDatabase { } }); } + + Future> getSearchHistory(String accountDid, {int limit = 50}) => + (select(searchHistory) + ..where((h) => h.accountDid.equals(accountDid)) + ..orderBy([(h) => OrderingTerm.desc(h.searchedAt)]) + ..limit(limit)) + .get(); + + Future insertSearchHistory(SearchHistoryCompanion entry) => into(searchHistory).insert(entry); + + Future deleteSearchHistoryEntry(int id) => (delete(searchHistory)..where((h) => h.id.equals(id))).go(); + + Future clearSearchHistory(String accountDid) => + (delete(searchHistory)..where((h) => h.accountDid.equals(accountDid))).go(); + + Future addSearchHistoryEntry({required String query, required String type, required String accountDid}) async { + await insertSearchHistory( + SearchHistoryCompanion( + query: Value(query), + type: Value(type), + accountDid: Value(accountDid), + searchedAt: Value(DateTime.now()), + ), + ); + + final entries = await getSearchHistory(accountDid, limit: 100); + if (entries.length > 50) { + final toDelete = entries.skip(50); + for (final entry in toDelete) { + await deleteSearchHistoryEntry(entry.id); + } + } + } } diff --git a/lib/core/database/app_database.g.dart b/lib/core/database/app_database.g.dart index b0ad7cd..d210c2f 100644 --- a/lib/core/database/app_database.g.dart +++ b/lib/core/database/app_database.g.dart @@ -1788,6 +1788,301 @@ class SavedFeedsCompanion extends UpdateCompanion { } } +class $SearchHistoryTable extends SearchHistory with TableInfo<$SearchHistoryTable, SearchHistoryEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SearchHistoryTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'), + ); + static const VerificationMeta _queryMeta = const VerificationMeta('query'); + @override + late final GeneratedColumn query = GeneratedColumn( + 'query', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _searchedAtMeta = const VerificationMeta('searchedAt'); + @override + late final GeneratedColumn searchedAt = GeneratedColumn( + 'searched_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _accountDidMeta = const VerificationMeta('accountDid'); + @override + late final GeneratedColumn accountDid = GeneratedColumn( + 'account_did', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + List get $columns => [id, query, type, searchedAt, accountDid]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'search_history'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('query')) { + context.handle(_queryMeta, query.isAcceptableOrUnknown(data['query']!, _queryMeta)); + } else if (isInserting) { + context.missing(_queryMeta); + } + if (data.containsKey('type')) { + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('searched_at')) { + context.handle(_searchedAtMeta, searchedAt.isAcceptableOrUnknown(data['searched_at']!, _searchedAtMeta)); + } + if (data.containsKey('account_did')) { + context.handle(_accountDidMeta, accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta)); + } else if (isInserting) { + context.missing(_accountDidMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SearchHistoryEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SearchHistoryEntry( + id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, + query: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}query'])!, + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, + searchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}searched_at'])!, + accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did'])!, + ); + } + + @override + $SearchHistoryTable createAlias(String alias) { + return $SearchHistoryTable(attachedDatabase, alias); + } +} + +class SearchHistoryEntry extends DataClass implements Insertable { + final int id; + final String query; + final String type; + final DateTime searchedAt; + final String accountDid; + const SearchHistoryEntry({ + required this.id, + required this.query, + required this.type, + required this.searchedAt, + required this.accountDid, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['query'] = Variable(query); + map['type'] = Variable(type); + map['searched_at'] = Variable(searchedAt); + map['account_did'] = Variable(accountDid); + return map; + } + + SearchHistoryCompanion toCompanion(bool nullToAbsent) { + return SearchHistoryCompanion( + id: Value(id), + query: Value(query), + type: Value(type), + searchedAt: Value(searchedAt), + accountDid: Value(accountDid), + ); + } + + factory SearchHistoryEntry.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SearchHistoryEntry( + id: serializer.fromJson(json['id']), + query: serializer.fromJson(json['query']), + type: serializer.fromJson(json['type']), + searchedAt: serializer.fromJson(json['searchedAt']), + accountDid: serializer.fromJson(json['accountDid']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'query': serializer.toJson(query), + 'type': serializer.toJson(type), + 'searchedAt': serializer.toJson(searchedAt), + 'accountDid': serializer.toJson(accountDid), + }; + } + + SearchHistoryEntry copyWith({int? id, String? query, String? type, DateTime? searchedAt, String? accountDid}) => + SearchHistoryEntry( + id: id ?? this.id, + query: query ?? this.query, + type: type ?? this.type, + searchedAt: searchedAt ?? this.searchedAt, + accountDid: accountDid ?? this.accountDid, + ); + SearchHistoryEntry copyWithCompanion(SearchHistoryCompanion data) { + return SearchHistoryEntry( + id: data.id.present ? data.id.value : this.id, + query: data.query.present ? data.query.value : this.query, + type: data.type.present ? data.type.value : this.type, + searchedAt: data.searchedAt.present ? data.searchedAt.value : this.searchedAt, + accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, + ); + } + + @override + String toString() { + return (StringBuffer('SearchHistoryEntry(') + ..write('id: $id, ') + ..write('query: $query, ') + ..write('type: $type, ') + ..write('searchedAt: $searchedAt, ') + ..write('accountDid: $accountDid') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, query, type, searchedAt, accountDid); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SearchHistoryEntry && + other.id == this.id && + other.query == this.query && + other.type == this.type && + other.searchedAt == this.searchedAt && + other.accountDid == this.accountDid); +} + +class SearchHistoryCompanion extends UpdateCompanion { + final Value id; + final Value query; + final Value type; + final Value searchedAt; + final Value accountDid; + const SearchHistoryCompanion({ + this.id = const Value.absent(), + this.query = const Value.absent(), + this.type = const Value.absent(), + this.searchedAt = const Value.absent(), + this.accountDid = const Value.absent(), + }); + SearchHistoryCompanion.insert({ + this.id = const Value.absent(), + required String query, + required String type, + this.searchedAt = const Value.absent(), + required String accountDid, + }) : query = Value(query), + type = Value(type), + accountDid = Value(accountDid); + static Insertable custom({ + Expression? id, + Expression? query, + Expression? type, + Expression? searchedAt, + Expression? accountDid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (query != null) 'query': query, + if (type != null) 'type': type, + if (searchedAt != null) 'searched_at': searchedAt, + if (accountDid != null) 'account_did': accountDid, + }); + } + + SearchHistoryCompanion copyWith({ + Value? id, + Value? query, + Value? type, + Value? searchedAt, + Value? accountDid, + }) { + return SearchHistoryCompanion( + id: id ?? this.id, + query: query ?? this.query, + type: type ?? this.type, + searchedAt: searchedAt ?? this.searchedAt, + accountDid: accountDid ?? this.accountDid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (query.present) { + map['query'] = Variable(query.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (searchedAt.present) { + map['searched_at'] = Variable(searchedAt.value); + } + if (accountDid.present) { + map['account_did'] = Variable(accountDid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SearchHistoryCompanion(') + ..write('id: $id, ') + ..write('query: $query, ') + ..write('type: $type, ') + ..write('searchedAt: $searchedAt, ') + ..write('accountDid: $accountDid') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); @@ -1796,10 +2091,18 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $CachedPostsTable cachedPosts = $CachedPostsTable(this); late final $SettingsTable settings = $SettingsTable(this); late final $SavedFeedsTable savedFeeds = $SavedFeedsTable(this); + late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [accounts, cachedProfiles, cachedPosts, settings, savedFeeds]; + List get allSchemaEntities => [ + accounts, + cachedProfiles, + cachedPosts, + settings, + savedFeeds, + searchHistory, + ]; } typedef $$AccountsTableCreateCompanionBuilder = @@ -2674,6 +2977,159 @@ typedef $$SavedFeedsTableProcessedTableManager = SavedFeedEntry, PrefetchHooks Function() >; +typedef $$SearchHistoryTableCreateCompanionBuilder = + SearchHistoryCompanion Function({ + Value id, + required String query, + required String type, + Value searchedAt, + required String accountDid, + }); +typedef $$SearchHistoryTableUpdateCompanionBuilder = + SearchHistoryCompanion Function({ + Value id, + Value query, + Value type, + Value searchedAt, + Value accountDid, + }); + +class $$SearchHistoryTableFilterComposer extends Composer<_$AppDatabase, $SearchHistoryTable> { + $$SearchHistoryTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get query => + $composableBuilder(column: $table.query, builder: (column) => ColumnFilters(column)); + + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); + + ColumnFilters get searchedAt => + $composableBuilder(column: $table.searchedAt, builder: (column) => ColumnFilters(column)); + + ColumnFilters get accountDid => + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnFilters(column)); +} + +class $$SearchHistoryTableOrderingComposer extends Composer<_$AppDatabase, $SearchHistoryTable> { + $$SearchHistoryTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get query => + $composableBuilder(column: $table.query, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get searchedAt => + $composableBuilder(column: $table.searchedAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get accountDid => + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnOrderings(column)); +} + +class $$SearchHistoryTableAnnotationComposer extends Composer<_$AppDatabase, $SearchHistoryTable> { + $$SearchHistoryTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get query => $composableBuilder(column: $table.query, builder: (column) => column); + + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get searchedAt => + $composableBuilder(column: $table.searchedAt, builder: (column) => column); + + GeneratedColumn get accountDid => $composableBuilder(column: $table.accountDid, builder: (column) => column); +} + +class $$SearchHistoryTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SearchHistoryTable, + SearchHistoryEntry, + $$SearchHistoryTableFilterComposer, + $$SearchHistoryTableOrderingComposer, + $$SearchHistoryTableAnnotationComposer, + $$SearchHistoryTableCreateCompanionBuilder, + $$SearchHistoryTableUpdateCompanionBuilder, + (SearchHistoryEntry, BaseReferences<_$AppDatabase, $SearchHistoryTable, SearchHistoryEntry>), + SearchHistoryEntry, + PrefetchHooks Function() + > { + $$SearchHistoryTableTableManager(_$AppDatabase db, $SearchHistoryTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$SearchHistoryTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$SearchHistoryTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$SearchHistoryTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value query = const Value.absent(), + Value type = const Value.absent(), + Value searchedAt = const Value.absent(), + Value accountDid = const Value.absent(), + }) => SearchHistoryCompanion( + id: id, + query: query, + type: type, + searchedAt: searchedAt, + accountDid: accountDid, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String query, + required String type, + Value searchedAt = const Value.absent(), + required String accountDid, + }) => SearchHistoryCompanion.insert( + id: id, + query: query, + type: type, + searchedAt: searchedAt, + accountDid: accountDid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SearchHistoryTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SearchHistoryTable, + SearchHistoryEntry, + $$SearchHistoryTableFilterComposer, + $$SearchHistoryTableOrderingComposer, + $$SearchHistoryTableAnnotationComposer, + $$SearchHistoryTableCreateCompanionBuilder, + $$SearchHistoryTableUpdateCompanionBuilder, + (SearchHistoryEntry, BaseReferences<_$AppDatabase, $SearchHistoryTable, SearchHistoryEntry>), + SearchHistoryEntry, + PrefetchHooks Function() + >; class $AppDatabaseManager { final _$AppDatabase _db; @@ -2683,4 +3139,5 @@ class $AppDatabaseManager { $$CachedPostsTableTableManager get cachedPosts => $$CachedPostsTableTableManager(_db, _db.cachedPosts); $$SettingsTableTableManager get settings => $$SettingsTableTableManager(_db, _db.settings); $$SavedFeedsTableTableManager get savedFeeds => $$SavedFeedsTableTableManager(_db, _db.savedFeeds); + $$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager(_db, _db.searchHistory); } diff --git a/lib/core/database/tables.dart b/lib/core/database/tables.dart index 16f8bce..be1b75b 100644 --- a/lib/core/database/tables.dart +++ b/lib/core/database/tables.dart @@ -65,3 +65,12 @@ class SavedFeeds extends Table { @override Set get primaryKey => {id, accountDid}; } + +@DataClassName('SearchHistoryEntry') +class SearchHistory extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get query => text()(); + TextColumn get type => text()(); + DateTimeColumn get searchedAt => dateTime().withDefault(currentDateAndTime)(); + TextColumn get accountDid => text()(); +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 94fab58..51939d5 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -10,6 +10,7 @@ import 'package:lazurite/features/feed/presentation/feed_management_screen.dart' import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; import 'package:lazurite/features/logs/presentation/logs_screen.dart'; import 'package:lazurite/features/profile/presentation/profile_screen.dart'; +import 'package:lazurite/features/search/presentation/search_screen.dart'; import 'package:lazurite/features/settings/presentation/about_screen.dart'; import 'package:lazurite/features/settings/presentation/settings_screen.dart'; @@ -19,6 +20,7 @@ class AppRouter { final NavigatorObserver? navigatorObserver; final GlobalKey _rootNavigatorKey = GlobalKey(debugLabel: 'root'); final GlobalKey _homeNavigatorKey = GlobalKey(debugLabel: 'home'); + final GlobalKey _searchNavigatorKey = GlobalKey(debugLabel: 'search'); final GlobalKey _profileNavigatorKey = GlobalKey(debugLabel: 'profile'); final GlobalKey _settingsNavigatorKey = GlobalKey(debugLabel: 'settings'); @@ -55,6 +57,10 @@ class AppRouter { ), ], ), + StatefulShellBranch( + navigatorKey: _searchNavigatorKey, + routes: [GoRoute(path: '/search', builder: (context, state) => const SearchScreen())], + ), StatefulShellBranch( navigatorKey: _profileNavigatorKey, routes: [ diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index a31a1ba..065ebcf 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -24,6 +24,7 @@ class AppShell extends StatelessWidget { List get _destinations => const [ NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), + NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), ]; diff --git a/lib/features/devtools/cubit/dev_tools_state.dart b/lib/features/devtools/cubit/dev_tools_state.dart index ddb0bc7..8847258 100644 --- a/lib/features/devtools/cubit/dev_tools_state.dart +++ b/lib/features/devtools/cubit/dev_tools_state.dart @@ -106,7 +106,9 @@ class DevToolsState extends Equatable { : selectedCollection as String?, records: identical(records, _devToolsStateNoChange) ? this.records : records as List?, recordsCursor: identical(recordsCursor, _devToolsStateNoChange) ? this.recordsCursor : recordsCursor as String?, - selectedRecord: identical(selectedRecord, _devToolsStateNoChange) ? this.selectedRecord : selectedRecord as RecordInfo?, + selectedRecord: identical(selectedRecord, _devToolsStateNoChange) + ? this.selectedRecord + : selectedRecord as RecordInfo?, errorMessage: identical(errorMessage, _devToolsStateNoChange) ? this.errorMessage : errorMessage as String?, ); } diff --git a/lib/features/search/bloc/search_bloc.dart b/lib/features/search/bloc/search_bloc.dart new file mode 100644 index 0000000..dcf3e81 --- /dev/null +++ b/lib/features/search/bloc/search_bloc.dart @@ -0,0 +1,267 @@ +import 'dart:async'; + +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; + +part 'search_state.dart'; + +class SearchBloc extends Bloc { + SearchBloc({required SearchRepository searchRepository, required AppDatabase database, required String accountDid}) + : _searchRepository = searchRepository, + _database = database, + _accountDid = accountDid, + super(const SearchState.initial()) { + on(_onQuerySubmitted); + on(_onSearchTabChanged); + on(_onSearchSortChanged); + on(_onLoadMoreRequested); + on(_onTypeaheadRequested); + on(_onTypeaheadResultsLoaded); + on(_onHistoryLoaded); + on(_onHistoryEntryDeleted); + on(_onHistoryCleared); + on(_onQueryCleared); + + add(const HistoryLoaded()); + } + + final SearchRepository _searchRepository; + final AppDatabase _database; + final String _accountDid; + Timer? _debounceTimer; + + Future _onQuerySubmitted(QuerySubmitted event, Emitter emit) async { + final query = event.query.trim(); + if (query.isEmpty) { + emit(const SearchState.initial()); + add(const HistoryLoaded()); + return; + } + + final currentTab = state.currentTab; + final currentSort = state.currentSort; + + if (currentTab == SearchTab.posts) { + emit(SearchState.loadingPosts(query: query, sort: currentSort)); + + try { + final result = await _searchRepository.searchPosts(query: query, sort: currentSort, limit: 50); + await _database.addSearchHistoryEntry(query: query, type: 'posts', accountDid: _accountDid); + final history = await _database.getSearchHistory(_accountDid, limit: 50); + + emit( + SearchState.loadedPosts( + query: query, + sort: currentSort, + posts: result.posts, + cursor: result.cursor, + hitsTotal: result.hitsTotal, + ).copyWith(searchHistory: history), + ); + } catch (error) { + emit(SearchState.error(query: query, message: 'Failed to search posts: $error')); + } + } else { + emit(SearchState.loadingActors(query: query)); + + try { + final result = await _searchRepository.searchActors(query: query, limit: 50); + await _database.addSearchHistoryEntry(query: query, type: 'actors', accountDid: _accountDid); + final history = await _database.getSearchHistory(_accountDid, limit: 50); + + emit( + SearchState.loadedActors( + query: query, + actors: result.actors, + cursor: result.cursor, + ).copyWith(searchHistory: history), + ); + } catch (error) { + emit(SearchState.error(query: query, message: 'Failed to search actors: $error')); + } + } + } + + Future _onSearchTabChanged(SearchTabChanged event, Emitter emit) async { + if (state.currentTab == event.tab) return; + + emit(state.copyWith(currentTab: event.tab)); + + if (state.query.isNotEmpty) { + add(QuerySubmitted(query: state.query)); + } + } + + Future _onSearchSortChanged(SearchSortChanged event, Emitter emit) async { + if (state.currentSort == event.sort) return; + + emit(state.copyWith(currentSort: event.sort)); + + if (state.query.isNotEmpty && state.currentTab == SearchTab.posts) { + add(QuerySubmitted(query: state.query)); + } + } + + Future _onLoadMoreRequested(LoadMoreRequested event, Emitter emit) async { + if (state.isLoadingMore || state.cursor == null) return; + + emit(state.copyWith(isLoadingMore: true)); + + try { + if (state.currentTab == SearchTab.posts) { + final result = await _searchRepository.searchPosts( + query: state.query, + sort: state.currentSort, + cursor: state.cursor, + limit: 50, + ); + + emit(state.copyWith(posts: [...state.posts, ...result.posts], cursor: result.cursor, isLoadingMore: false)); + } else { + final result = await _searchRepository.searchActors(query: state.query, cursor: state.cursor, limit: 50); + + emit(state.copyWith(actors: [...state.actors, ...result.actors], cursor: result.cursor, isLoadingMore: false)); + } + } catch (error) { + emit(state.copyWith(isLoadingMore: false)); + } + } + + Future _onTypeaheadRequested(TypeaheadRequested event, Emitter emit) async { + final query = event.query.trim(); + + _debounceTimer?.cancel(); + + if (!query.startsWith('@')) { + emit(state.copyWith(typeaheadActors: [])); + return; + } + + final handleQuery = query.substring(1).trim(); + if (handleQuery.isEmpty) { + emit(state.copyWith(typeaheadActors: [])); + return; + } + + _debounceTimer = Timer(const Duration(milliseconds: 300), () { + add(TypeaheadResultsLoaded(query: handleQuery)); + }); + } + + Future _onTypeaheadResultsLoaded(TypeaheadResultsLoaded event, Emitter emit) async { + try { + final actors = await _searchRepository.searchActorsTypeahead(query: event.query, limit: 5); + emit(state.copyWith(typeaheadActors: actors)); + } catch (_) { + emit(state.copyWith(typeaheadActors: [])); + } + } + + Future _onHistoryLoaded(HistoryLoaded event, Emitter emit) async { + final entries = await _database.getSearchHistory(_accountDid, limit: 50); + emit(state.copyWith(searchHistory: entries)); + } + + Future _onHistoryEntryDeleted(HistoryEntryDeleted event, Emitter emit) async { + await _database.deleteSearchHistoryEntry(event.id); + final entries = await _database.getSearchHistory(_accountDid, limit: 50); + emit(state.copyWith(searchHistory: entries)); + } + + Future _onHistoryCleared(HistoryCleared event, Emitter emit) async { + await _database.clearSearchHistory(_accountDid); + emit(state.copyWith(searchHistory: [])); + } + + void _onQueryCleared(QueryCleared event, Emitter emit) { + emit(const SearchState.initial()); + add(const HistoryLoaded()); + } + + @override + Future close() { + _debounceTimer?.cancel(); + return super.close(); + } +} + +abstract class SearchEvent extends Equatable { + const SearchEvent(); + + @override + List get props => []; +} + +class QuerySubmitted extends SearchEvent { + const QuerySubmitted({required this.query}); + + final String query; + + @override + List get props => [query]; +} + +class SearchTabChanged extends SearchEvent { + const SearchTabChanged({required this.tab}); + + final SearchTab tab; + + @override + List get props => [tab]; +} + +class SearchSortChanged extends SearchEvent { + const SearchSortChanged({required this.sort}); + + final String sort; + + @override + List get props => [sort]; +} + +class LoadMoreRequested extends SearchEvent { + const LoadMoreRequested(); +} + +class TypeaheadRequested extends SearchEvent { + const TypeaheadRequested({required this.query}); + + final String query; + + @override + List get props => [query]; +} + +class TypeaheadResultsLoaded extends SearchEvent { + const TypeaheadResultsLoaded({required this.query}); + + final String query; + + @override + List get props => [query]; +} + +class HistoryLoaded extends SearchEvent { + const HistoryLoaded(); +} + +class HistoryEntryDeleted extends SearchEvent { + const HistoryEntryDeleted({required this.id}); + + final int id; + + @override + List get props => [id]; +} + +class HistoryCleared extends SearchEvent { + const HistoryCleared(); +} + +class QueryCleared extends SearchEvent { + const QueryCleared(); +} diff --git a/lib/features/search/bloc/search_state.dart b/lib/features/search/bloc/search_state.dart new file mode 100644 index 0000000..4cf9138 --- /dev/null +++ b/lib/features/search/bloc/search_state.dart @@ -0,0 +1,141 @@ +part of 'search_bloc.dart'; + +enum SearchTab { posts, actors } + +extension SearchTabLabel on SearchTab { + String get label => switch (this) { + SearchTab.posts => 'Posts', + SearchTab.actors => 'People', + }; +} + +enum SearchSort { + top, + latest; + + factory SearchSort.fromString(String value) { + return switch (value) { + 'top' => SearchSort.top, + _ => SearchSort.latest, + }; + } +} + +extension SearchSortLabel on SearchSort { + String get label => switch (this) { + SearchSort.top => 'Top', + SearchSort.latest => 'Latest', + }; +} + +enum SearchStatus { initial, loading, loaded, error } + +class SearchState extends Equatable { + const SearchState._({ + required this.status, + this.query = '', + this.currentTab = SearchTab.posts, + this.currentSort = 'top', + this.posts = const [], + this.actors = const [], + this.cursor, + this.hitsTotal, + this.errorMessage, + this.isLoadingMore = false, + this.typeaheadActors = const [], + this.searchHistory = const [], + }); + + const SearchState.initial() : this._(status: SearchStatus.initial); + + const SearchState.loadingPosts({required String query, required String sort}) + : this._(status: SearchStatus.loading, query: query, currentSort: sort); + + const SearchState.loadingActors({required String query}) + : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.actors); + + const SearchState.loadedPosts({ + required String query, + required String sort, + required List posts, + String? cursor, + int? hitsTotal, + }) : this._( + status: SearchStatus.loaded, + query: query, + currentSort: sort, + posts: posts, + cursor: cursor, + hitsTotal: hitsTotal, + ); + + const SearchState.loadedActors({required String query, required List actors, String? cursor}) + : this._(status: SearchStatus.loaded, query: query, currentTab: SearchTab.actors, actors: actors, cursor: cursor); + + const SearchState.error({required String query, required String message}) + : this._(status: SearchStatus.error, query: query, errorMessage: message); + + final SearchStatus status; + final String query; + final SearchTab currentTab; + final String currentSort; + final List posts; + final List actors; + final String? cursor; + final int? hitsTotal; + final String? errorMessage; + final bool isLoadingMore; + final List typeaheadActors; + final List searchHistory; + + bool get isLoading => status == SearchStatus.loading; + bool get hasError => status == SearchStatus.error; + bool get hasResults => posts.isNotEmpty || actors.isNotEmpty; + bool get hasMore => cursor != null; + + SearchState copyWith({ + SearchStatus? status, + String? query, + SearchTab? currentTab, + String? currentSort, + List? posts, + List? actors, + String? cursor, + int? hitsTotal, + String? errorMessage, + bool? isLoadingMore, + List? typeaheadActors, + List? searchHistory, + }) { + return SearchState._( + status: status ?? this.status, + query: query ?? this.query, + currentTab: currentTab ?? this.currentTab, + currentSort: currentSort ?? this.currentSort, + posts: posts ?? this.posts, + actors: actors ?? this.actors, + cursor: cursor ?? this.cursor, + hitsTotal: hitsTotal ?? this.hitsTotal, + errorMessage: errorMessage ?? this.errorMessage, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + typeaheadActors: typeaheadActors ?? this.typeaheadActors, + searchHistory: searchHistory ?? this.searchHistory, + ); + } + + @override + List get props => [ + status, + query, + currentTab, + currentSort, + posts, + actors, + cursor, + hitsTotal, + errorMessage, + isLoadingMore, + typeaheadActors, + searchHistory, + ]; +} diff --git a/lib/features/search/data/search_repository.dart b/lib/features/search/data/search_repository.dart new file mode 100644 index 0000000..fb74798 --- /dev/null +++ b/lib/features/search/data/search_repository.dart @@ -0,0 +1,56 @@ +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_feed_searchposts.dart'; +import 'package:bluesky/bluesky.dart'; + +class SearchRepository { + SearchRepository({required Bluesky bluesky}) : _bluesky = bluesky; + + final Bluesky _bluesky; + + Future searchPosts({ + required String query, + String sort = 'top', + String? cursor, + int limit = 50, + }) async { + final sortValue = sort == 'latest' + ? const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest) + : const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.top); + + final response = await _bluesky.feed.searchPosts(q: query, sort: sortValue, cursor: cursor, limit: limit); + + return SearchPostsResult( + posts: response.data.posts, + cursor: response.data.cursor, + hitsTotal: response.data.hitsTotal, + ); + } + + Future searchActors({required String query, String? cursor, int limit = 50}) async { + final response = await _bluesky.actor.searchActors(q: query, cursor: cursor, limit: limit); + + return SearchActorsResult(actors: response.data.actors, cursor: response.data.cursor); + } + + Future> searchActorsTypeahead({required String query, int limit = 10}) async { + final response = await _bluesky.actor.searchActorsTypeahead(q: query, limit: limit); + + return response.data.actors; + } +} + +class SearchPostsResult { + SearchPostsResult({required this.posts, this.cursor, this.hitsTotal}); + + final List posts; + final String? cursor; + final int? hitsTotal; +} + +class SearchActorsResult { + SearchActorsResult({required this.actors, this.cursor}); + + final List actors; + final String? cursor; +} diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart new file mode 100644 index 0000000..ebafd9a --- /dev/null +++ b/lib/features/search/presentation/search_screen.dart @@ -0,0 +1,796 @@ +import 'package:bluesky/app_bsky_actor_defs.dart'; +import 'package:bluesky/app_bsky_feed_defs.dart'; +import 'package:bluesky/app_bsky_feed_post.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; +import 'package:lazurite/features/search/bloc/search_bloc.dart'; + +class SearchScreen extends StatefulWidget { + const SearchScreen({super.key}); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + final TextEditingController _searchController = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _searchController.addListener(_onSearchChanged); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onSearchChanged() { + setState(() {}); + context.read().add(TypeaheadRequested(query: _searchController.text)); + } + + void _onScroll() { + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { + context.read().add(const LoadMoreRequested()); + } + } + + void _onSubmit(String query) { + if (query.trim().isEmpty) return; + context.read().add(QuerySubmitted(query: query)); + _focusNode.unfocus(); + } + + void _onCancel() { + _searchController.clear(); + setState(() {}); + context.read().add(const QueryCleared()); + } + + void _onTabChanged(SearchTab tab) { + context.read().add(SearchTabChanged(tab: tab)); + } + + void _onSortChanged(String sort) { + context.read().add(SearchSortChanged(sort: sort)); + } + + void _onHistoryTap(String query, String type) { + _searchController.text = query; + final tab = type == 'posts' ? SearchTab.posts : SearchTab.actors; + context.read().add(SearchTabChanged(tab: tab)); + context.read().add(QuerySubmitted(query: query)); + _focusNode.unfocus(); + } + + void _onHistoryDelete(int id) { + context.read().add(HistoryEntryDeleted(id: id)); + } + + void _onClearHistory() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear search history?'), + content: const Text('This will delete all your recent searches.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton( + onPressed: () { + Navigator.pop(context); + context.read().add(const HistoryCleared()); + }, + child: const Text('Clear'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + _buildSearchBar(context, state), + _buildTabs(context, state), + if (state.currentTab == SearchTab.posts && state.hasResults) _buildSortToggle(context, state), + Expanded(child: _buildBody(context, state)), + ], + ); + }, + ), + ), + ); + } + + Widget _buildSearchBar(BuildContext context, SearchState state) { + final hasText = _searchController.text.isNotEmpty; + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.dividerColor)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + onSubmitted: _onSubmit, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Search posts or people', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: hasText ? _buildSuffixIcon(context, theme) : null, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(999)), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(999), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(999), + borderSide: BorderSide(color: theme.colorScheme.primary), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSuffixIcon(BuildContext context, ThemeData theme) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _onCancel, + icon: const Icon(Icons.close, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + ), + IconButton( + onPressed: () => _onSubmit(_searchController.text), + icon: Icon(Icons.arrow_forward_rounded, size: 20, color: theme.colorScheme.primary), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), + ), + ], + ); + } + + Widget _buildTabs(BuildContext context, SearchState state) { + return Container( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Row(children: [_buildTab(context, SearchTab.posts, state), _buildTab(context, SearchTab.actors, state)]), + ); + } + + Widget _buildTab(BuildContext context, SearchTab tab, SearchState state) { + final isSelected = state.currentTab == tab; + final theme = Theme.of(context); + final textStyle = theme.textTheme.bodyLarge?.copyWith( + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, + ); + return Expanded( + child: InkWell( + onTap: () => _onTabChanged(tab), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), + ), + ), + child: Text(tab.label, textAlign: TextAlign.center, style: textStyle), + ), + ), + ); + } + + Widget _buildSortToggle(BuildContext context, SearchState state) { + final theme = Theme.of(context); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: theme.dividerColor)), + ), + child: Row( + children: [ + Text('Sort by', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(width: 8), + Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSortOption(context, SearchSort.top, state), + _buildSortOption(context, SearchSort.latest, state), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSortOption(BuildContext context, SearchSort sort, SearchState state) { + final isSelected = state.currentSort == sort.name; + final theme = Theme.of(context); + final labelColor = isSelected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurfaceVariant; + + return GestureDetector( + onTap: () => _onSortChanged(sort.name), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? theme.colorScheme.primary : null, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + sort.label, + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500, color: labelColor), + ), + ), + ); + } + + Widget _buildBody(BuildContext context, SearchState state) { + if (state.typeaheadActors.isNotEmpty && _searchController.text.startsWith('@')) { + return _buildTypeaheadResults(context, state); + } + + if (state.query.isEmpty) { + return _buildSearchHistory(context, state); + } + + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.hasError) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Search failed', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + state.errorMessage ?? 'Unknown error', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: () => context.read().add(QuerySubmitted(query: state.query)), + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (state.currentTab == SearchTab.posts) { + return _buildPostResults(context, state); + } else { + return _buildActorResults(context, state); + } + } + + Widget _buildSearchHistory(BuildContext context, SearchState state) { + final history = state.searchHistory; + if (history.isEmpty) { + return const _SearchEmptyState(); + } + + return Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Recent Searches', style: Theme.of(context).textTheme.titleSmall), + TextButton(onPressed: _onClearHistory, child: const Text('Clear All')), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: history.length, + itemBuilder: (context, index) { + final entry = history[index]; + final label = entry.type == 'posts' ? 'Posts' : 'People'; + return Dismissible( + key: Key('history_${entry.id}'), + direction: DismissDirection.endToStart, + onDismissed: (_) => _onHistoryDelete(entry.id), + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + color: Theme.of(context).colorScheme.error, + child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onError), + ), + child: ListTile( + leading: const Icon(Icons.history), + title: Text(entry.query), + subtitle: Text('$label · ${_formatHistoryTime(entry.searchedAt)}'), + onTap: () => _onHistoryTap(entry.query, entry.type), + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildTypeaheadResults(BuildContext context, SearchState state) { + return ListView.builder( + itemCount: state.typeaheadActors.length, + itemBuilder: (context, index) { + final actor = state.typeaheadActors[index]; + return _ActorListTile( + actor: actor, + onTap: () { + _searchController.text = '@${actor.handle}'; + _onSubmit('@${actor.handle}'); + }, + ); + }, + ); + } + + Widget _buildPostResults(BuildContext context, SearchState state) { + final posts = state.posts; + if (posts.isEmpty) { + return Center(child: Text('No posts found', style: Theme.of(context).textTheme.bodyLarge)); + } + + return ListView.builder( + controller: _scrollController, + itemCount: posts.length + (state.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == posts.length) { + return const Center( + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), + ); + } + return _PostViewCard(post: posts[index]); + }, + ); + } + + Widget _buildActorResults(BuildContext context, SearchState state) { + final actors = state.actors; + if (actors.isEmpty) { + return Center(child: Text('No people found', style: Theme.of(context).textTheme.bodyLarge)); + } + + return ListView.builder( + controller: _scrollController, + itemCount: actors.length + (state.isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == actors.length) { + return const Center( + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), + ); + } + return _ActorResultTile(actor: actors[index]); + }, + ); + } + + String _formatHistoryTime(DateTime time) { + final now = DateTime.now(); + final difference = now.difference(time); + + if (difference.inMinutes < 1) { + return 'Just now'; + } + if (difference.inHours < 1) { + return '${difference.inMinutes}m ago'; + } + if (difference.inDays < 1) { + return '${difference.inHours}h ago'; + } + if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } + return DateFormat('MMM d').format(time); + } +} + +class _PostViewCard extends StatelessWidget { + const _PostViewCard({required this.post}); + + final PostView post; + + @override + Widget build(BuildContext context) { + final record = _tryParseRecord(post.record); + final createdAt = record?.createdAt ?? post.indexedAt; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), + elevation: 0, + shape: const RoundedRectangleBorder(), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(context, post.author, createdAt), + if (record != null && record.text.isNotEmpty) ...[ + const SizedBox(height: 12), + FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), + ], + const SizedBox(height: 12), + _buildActions(context), + ], + ), + ), + ); + } + + Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { + return InkWell( + onTap: () => _navigateToProfile(context, author.did), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 22, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, + child: author.avatar == null + ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + author.displayName ?? author.handle, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '@${author.handle} · ${_formatTime(createdAt)}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActions(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'), + _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'), + _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'), + _buildActionButton(context, Icons.share_outlined, ''), + ], + ); + } + + Widget _buildActionButton(BuildContext context, IconData icon, String count) { + final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; + + return InkWell( + onTap: () {}, + borderRadius: BorderRadius.circular(999), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + Icon(icon, size: 18, color: iconColor), + if (count.isNotEmpty) ...[ + const SizedBox(width: 4), + Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), + ], + ], + ), + ), + ); + } + + void _navigateToProfile(BuildContext context, String did) { + final router = GoRouter.maybeOf(context); + if (router != null) { + router.push('/profile/view?actor=${Uri.encodeQueryComponent(did)}'); + } + } + + FeedPostRecord? _tryParseRecord(Map record) { + try { + return FeedPostRecord.fromJson(record); + } catch (_) { + return null; + } + } + + String _formatTime(DateTime time) { + final now = DateTime.now(); + final difference = now.difference(time); + + if (difference.inMinutes < 1) { + return 'now'; + } + if (difference.inHours < 1) { + return '${difference.inMinutes}m'; + } + if (difference.inDays < 1) { + return '${difference.inHours}h'; + } + if (difference.inDays < 7) { + return '${difference.inDays}d'; + } + return DateFormat('MMM d').format(time); + } + + String _initials(String value) { + final parts = value.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) { + return '?'; + } + if (parts.length == 1) { + return parts.first.substring(0, 1).toUpperCase(); + } + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); + } +} + +class _ActorResultTile extends StatelessWidget { + const _ActorResultTile({required this.actor}); + + final ProfileView actor; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => _navigateToProfile(context, actor.did), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Row( + children: [ + CircleAvatar( + radius: 24, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: actor.avatar != null ? NetworkImage(actor.avatar!) : null, + child: actor.avatar == null + ? Text(_initials(actor.displayName ?? actor.handle), style: Theme.of(context).textTheme.labelLarge) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + actor.displayName ?? actor.handle, + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '@${actor.handle}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + if (actor.description != null && actor.description!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + actor.description!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _FollowButton(actor: actor), + ], + ), + ), + ); + } + + void _navigateToProfile(BuildContext context, String did) { + final router = GoRouter.maybeOf(context); + if (router != null) { + router.push('/profile/view?actor=${Uri.encodeQueryComponent(did)}'); + } + } + + String _initials(String value) { + final parts = value.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) { + return '?'; + } + if (parts.length == 1) { + return parts.first.substring(0, 1).toUpperCase(); + } + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); + } +} + +class _ActorListTile extends StatelessWidget { + const _ActorListTile({required this.actor, required this.onTap}); + + final ProfileViewBasic actor; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, + backgroundImage: actor.avatar != null ? NetworkImage(actor.avatar!) : null, + child: actor.avatar == null + ? Text(_initials(actor.displayName ?? actor.handle), style: Theme.of(context).textTheme.labelMedium) + : null, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + actor.displayName ?? actor.handle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + '@${actor.handle}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _initials(String value) { + final parts = value.trim().split(RegExp(r'\s+')); + if (parts.isEmpty || parts.first.isEmpty) { + return '?'; + } + if (parts.length == 1) { + return parts.first.substring(0, 1).toUpperCase(); + } + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); + } +} + +class _FollowButton extends StatefulWidget { + const _FollowButton({required this.actor}); + + final ProfileView actor; + + @override + State<_FollowButton> createState() => _FollowButtonState(); +} + +class _FollowButtonState extends State<_FollowButton> { + late bool _isFollowing; + + @override + void initState() { + super.initState(); + _isFollowing = widget.actor.viewer?.following != null; + } + + @override + Widget build(BuildContext context) { + if (_isFollowing) { + return OutlinedButton( + onPressed: _toggleFollow, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + child: const Text('Following'), + ); + } + + return FilledButton.tonal( + onPressed: _toggleFollow, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), + ), + child: const Text('Follow'), + ); + } + + void _toggleFollow() { + setState(() => _isFollowing = !_isFollowing); + } +} + +class _SearchEmptyState extends StatelessWidget { + const _SearchEmptyState(); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search, size: 64, color: Theme.of(context).colorScheme.outline), + const SizedBox(height: 16), + Text('Search', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Find posts and people on the network.\n' + 'Type @ to search for users.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4cd99a1..b0ec6b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,8 @@ import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; +import 'package:lazurite/features/search/bloc/search_bloc.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/features/settings/bloc/settings_state.dart'; @@ -114,6 +116,7 @@ class _LazuriteAppState extends State { } final feedRepository = FeedRepository(bluesky: bluesky); + final searchRepository = SearchRepository(bluesky: bluesky); final accountDid = authState.tokens?.did ?? ''; return MultiBlocProvider( @@ -132,7 +135,12 @@ class _LazuriteAppState extends State { )..loadPreferences(), ), BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), + BlocProvider( + create: (_) => + SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), + ), RepositoryProvider.value(value: feedRepository), + RepositoryProvider.value(value: searchRepository), ], child: appShell, ); diff --git a/test/features/search/presentation/search_screen_test.dart b/test/features/search/presentation/search_screen_test.dart new file mode 100644 index 0000000..4c2dfea --- /dev/null +++ b/test/features/search/presentation/search_screen_test.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/features/search/bloc/search_bloc.dart'; +import 'package:lazurite/features/search/data/search_repository.dart'; +import 'package:lazurite/features/search/presentation/search_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockSearchRepository extends Mock implements SearchRepository {} + +class MockAppDatabase extends Mock implements AppDatabase {} + +void main() { + group('SearchScreen', () { + late MockSearchRepository mockSearchRepository; + late MockAppDatabase mockDatabase; + + setUp(() { + mockSearchRepository = MockSearchRepository(); + mockDatabase = MockAppDatabase(); + when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); + when( + () => mockSearchRepository.searchPosts( + query: any(named: 'query'), + sort: any(named: 'sort'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => SearchPostsResult(posts: [])); + when( + () => mockSearchRepository.searchActors( + query: any(named: 'query'), + cursor: any(named: 'cursor'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => SearchActorsResult(actors: [])); + when( + () => mockSearchRepository.searchActorsTypeahead( + query: any(named: 'query'), + limit: any(named: 'limit'), + ), + ).thenAnswer((_) async => []); + }); + + Widget buildSubject() { + return MaterialApp( + home: BlocProvider( + create: (_) => + SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), + child: const SearchScreen(), + ), + ); + } + + testWidgets('displays search input and tabs', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Search posts or people'), findsOneWidget); + expect(find.text('Posts'), findsOneWidget); + expect(find.text('People'), findsOneWidget); + expect(find.text('Top'), findsOneWidget); + expect(find.text('Latest'), findsOneWidget); + }); + + testWidgets('shows empty state when no search history', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + expect(find.text('Search'), findsOneWidget); + expect(find.textContaining('Find posts and people'), findsOneWidget); + }); + + testWidgets('tab switching works correctly', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final peopleTab = find.text('People'); + await tester.tap(peopleTab); + await tester.pumpAndSettle(); + + final postsTab = find.text('Posts'); + await tester.tap(postsTab); + await tester.pumpAndSettle(); + }); + + testWidgets('sort toggle changes correctly', (tester) async { + await tester.pumpWidget(buildSubject()); + await tester.pumpAndSettle(); + + final latestButton = find.text('Latest'); + await tester.tap(latestButton); + await tester.pumpAndSettle(); + }); + + testWidgets('shows search history when available', (tester) async { + final historyEntry = SearchHistoryEntry( + id: 1, + query: 'flutter', + type: 'posts', + searchedAt: DateTime.now(), + accountDid: 'did:plc:test', + ); + + when( + () => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit')), + ).thenAnswer((_) async => [historyEntry]); + + await tester.pumpWidget( + MaterialApp( + home: BlocProvider( + create: (_) => + SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), + child: const SearchScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Recent Searches'), findsOneWidget); + expect(find.text('flutter'), findsOneWidget); + }); + }); +}