mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: search with persistence

+1948 -18
+32 -6
docs/designs/settings.html
··· 248 248 <div class="theme-swatch" style="background-color: #08bdba"></div> 249 249 <div class="theme-swatch" style="background-color: #ee5396"></div> 250 250 </div> 251 - <svg class="theme-palette-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> 251 + <svg 252 + class="theme-palette-check" 253 + viewBox="0 0 24 24" 254 + fill="none" 255 + stroke="currentColor" 256 + stroke-width="3" 257 + stroke-linecap="round" 258 + stroke-linejoin="round"> 252 259 <polyline points="20 6 9 17 4 12" /> 253 260 </svg> 254 261 </div> ··· 261 268 <div class="theme-swatch" style="background-color: #74c7ec"></div> 262 269 <div class="theme-swatch" style="background-color: #f5c2e7"></div> 263 270 </div> 264 - <svg class="theme-palette-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> 271 + <svg 272 + class="theme-palette-check" 273 + viewBox="0 0 24 24" 274 + fill="none" 275 + stroke="currentColor" 276 + stroke-width="3" 277 + stroke-linecap="round" 278 + stroke-linejoin="round"> 265 279 <polyline points="20 6 9 17 4 12" /> 266 280 </svg> 267 281 </div> ··· 274 288 <div class="theme-swatch" style="background-color: #ebcb8b"></div> 275 289 <div class="theme-swatch" style="background-color: #b48ead"></div> 276 290 </div> 277 - <svg class="theme-palette-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> 291 + <svg 292 + class="theme-palette-check" 293 + viewBox="0 0 24 24" 294 + fill="none" 295 + stroke="currentColor" 296 + stroke-width="3" 297 + stroke-linecap="round" 298 + stroke-linejoin="round"> 278 299 <polyline points="20 6 9 17 4 12" /> 279 300 </svg> 280 301 </div> ··· 287 308 <div class="theme-swatch" style="background-color: #9ccfd8"></div> 288 309 <div class="theme-swatch" style="background-color: #f6c177"></div> 289 310 </div> 290 - <svg class="theme-palette-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"> 311 + <svg 312 + class="theme-palette-check" 313 + viewBox="0 0 24 24" 314 + fill="none" 315 + stroke="currentColor" 316 + stroke-width="3" 317 + stroke-linecap="round" 318 + stroke-linejoin="round"> 291 319 <polyline points="20 6 9 17 4 12" /> 292 320 </svg> 293 321 </div> ··· 685 713 const mode = localStorage.getItem("appearance-mode") || "dark"; 686 714 const palette = localStorage.getItem("palette") || "oxocarbon"; 687 715 688 - // Restore mode 689 716 document.querySelectorAll(".segment-btn").forEach((el) => el.classList.remove("active")); 690 717 const modeBtn = document.querySelector(`.segment-btn[data-mode="${mode}"]`); 691 718 if (modeBtn) modeBtn.classList.add("active"); ··· 697 724 applyVariant(mode); 698 725 } 699 726 700 - // Restore palette 701 727 document.querySelectorAll(".theme-palette-row").forEach((el) => el.classList.remove("active")); 702 728 const paletteRow = document.querySelector(`.theme-palette-row[data-palette="${palette}"]`); 703 729 if (paletteRow) paletteRow.classList.add("active");
+8 -8
docs/tasks/phase-2.md
··· 27 27 28 28 ## M6 — Search 29 29 30 - - [ ] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors) 31 - - [ ] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted` 32 - - [ ] Post search via `searchPosts` with paginated results 33 - - [ ] Actor search via `searchActors` with paginated results 34 - - [ ] Typeahead autocomplete via `searchActorsTypeahead` 35 - - [ ] Drift migration: add `search_history` table (query, type, searched_at, account_did) 36 - - [ ] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account 37 - - [ ] Search with `@` should autocomplete with avatars + handles (debounced) 30 + - [x] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors) 31 + - [x] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted` 32 + - [x] Post search via `searchPosts` with paginated results 33 + - [x] Actor search via `searchActors` with paginated results 34 + - [x] Typeahead autocomplete via `searchActorsTypeahead` 35 + - [x] Drift migration: add `search_history` table (query, type, searched_at, account_did) 36 + - [x] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account 37 + - [x] Search with `@` should autocomplete with avatars + handles (debounced) 38 38 39 39 ## M7 — Dev Tools (PDS Explorer) 40 40
+38 -2
lib/core/database/app_database.dart
··· 6 6 7 7 part 'app_database.g.dart'; 8 8 9 - @DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings, SavedFeeds]) 9 + @DriftDatabase(tables: [Accounts, CachedProfiles, CachedPosts, Settings, SavedFeeds, SearchHistory]) 10 10 class AppDatabase extends _$AppDatabase { 11 11 AppDatabase({QueryExecutor? executor}) : super(executor ?? _openConnection()); 12 12 13 13 @override 14 - int get schemaVersion => 3; 14 + int get schemaVersion => 4; 15 15 16 16 @override 17 17 MigrationStrategy get migration => MigrationStrategy( ··· 28 28 } 29 29 if (from < 3) { 30 30 await migrator.createTable(savedFeeds); 31 + } 32 + if (from < 4) { 33 + await migrator.createTable(searchHistory); 31 34 } 32 35 }, 33 36 ); ··· 139 142 await insertSavedFeed(feed); 140 143 } 141 144 }); 145 + } 146 + 147 + Future<List<SearchHistoryEntry>> getSearchHistory(String accountDid, {int limit = 50}) => 148 + (select(searchHistory) 149 + ..where((h) => h.accountDid.equals(accountDid)) 150 + ..orderBy([(h) => OrderingTerm.desc(h.searchedAt)]) 151 + ..limit(limit)) 152 + .get(); 153 + 154 + Future<int> insertSearchHistory(SearchHistoryCompanion entry) => into(searchHistory).insert(entry); 155 + 156 + Future<int> deleteSearchHistoryEntry(int id) => (delete(searchHistory)..where((h) => h.id.equals(id))).go(); 157 + 158 + Future<int> clearSearchHistory(String accountDid) => 159 + (delete(searchHistory)..where((h) => h.accountDid.equals(accountDid))).go(); 160 + 161 + Future<void> addSearchHistoryEntry({required String query, required String type, required String accountDid}) async { 162 + await insertSearchHistory( 163 + SearchHistoryCompanion( 164 + query: Value(query), 165 + type: Value(type), 166 + accountDid: Value(accountDid), 167 + searchedAt: Value(DateTime.now()), 168 + ), 169 + ); 170 + 171 + final entries = await getSearchHistory(accountDid, limit: 100); 172 + if (entries.length > 50) { 173 + final toDelete = entries.skip(50); 174 + for (final entry in toDelete) { 175 + await deleteSearchHistoryEntry(entry.id); 176 + } 177 + } 142 178 } 143 179 }
+458 -1
lib/core/database/app_database.g.dart
··· 1788 1788 } 1789 1789 } 1790 1790 1791 + class $SearchHistoryTable extends SearchHistory with TableInfo<$SearchHistoryTable, SearchHistoryEntry> { 1792 + @override 1793 + final GeneratedDatabase attachedDatabase; 1794 + final String? _alias; 1795 + $SearchHistoryTable(this.attachedDatabase, [this._alias]); 1796 + static const VerificationMeta _idMeta = const VerificationMeta('id'); 1797 + @override 1798 + late final GeneratedColumn<int> id = GeneratedColumn<int>( 1799 + 'id', 1800 + aliasedName, 1801 + false, 1802 + hasAutoIncrement: true, 1803 + type: DriftSqlType.int, 1804 + requiredDuringInsert: false, 1805 + defaultConstraints: GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'), 1806 + ); 1807 + static const VerificationMeta _queryMeta = const VerificationMeta('query'); 1808 + @override 1809 + late final GeneratedColumn<String> query = GeneratedColumn<String>( 1810 + 'query', 1811 + aliasedName, 1812 + false, 1813 + type: DriftSqlType.string, 1814 + requiredDuringInsert: true, 1815 + ); 1816 + static const VerificationMeta _typeMeta = const VerificationMeta('type'); 1817 + @override 1818 + late final GeneratedColumn<String> type = GeneratedColumn<String>( 1819 + 'type', 1820 + aliasedName, 1821 + false, 1822 + type: DriftSqlType.string, 1823 + requiredDuringInsert: true, 1824 + ); 1825 + static const VerificationMeta _searchedAtMeta = const VerificationMeta('searchedAt'); 1826 + @override 1827 + late final GeneratedColumn<DateTime> searchedAt = GeneratedColumn<DateTime>( 1828 + 'searched_at', 1829 + aliasedName, 1830 + false, 1831 + type: DriftSqlType.dateTime, 1832 + requiredDuringInsert: false, 1833 + defaultValue: currentDateAndTime, 1834 + ); 1835 + static const VerificationMeta _accountDidMeta = const VerificationMeta('accountDid'); 1836 + @override 1837 + late final GeneratedColumn<String> accountDid = GeneratedColumn<String>( 1838 + 'account_did', 1839 + aliasedName, 1840 + false, 1841 + type: DriftSqlType.string, 1842 + requiredDuringInsert: true, 1843 + ); 1844 + @override 1845 + List<GeneratedColumn> get $columns => [id, query, type, searchedAt, accountDid]; 1846 + @override 1847 + String get aliasedName => _alias ?? actualTableName; 1848 + @override 1849 + String get actualTableName => $name; 1850 + static const String $name = 'search_history'; 1851 + @override 1852 + VerificationContext validateIntegrity(Insertable<SearchHistoryEntry> instance, {bool isInserting = false}) { 1853 + final context = VerificationContext(); 1854 + final data = instance.toColumns(true); 1855 + if (data.containsKey('id')) { 1856 + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); 1857 + } 1858 + if (data.containsKey('query')) { 1859 + context.handle(_queryMeta, query.isAcceptableOrUnknown(data['query']!, _queryMeta)); 1860 + } else if (isInserting) { 1861 + context.missing(_queryMeta); 1862 + } 1863 + if (data.containsKey('type')) { 1864 + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); 1865 + } else if (isInserting) { 1866 + context.missing(_typeMeta); 1867 + } 1868 + if (data.containsKey('searched_at')) { 1869 + context.handle(_searchedAtMeta, searchedAt.isAcceptableOrUnknown(data['searched_at']!, _searchedAtMeta)); 1870 + } 1871 + if (data.containsKey('account_did')) { 1872 + context.handle(_accountDidMeta, accountDid.isAcceptableOrUnknown(data['account_did']!, _accountDidMeta)); 1873 + } else if (isInserting) { 1874 + context.missing(_accountDidMeta); 1875 + } 1876 + return context; 1877 + } 1878 + 1879 + @override 1880 + Set<GeneratedColumn> get $primaryKey => {id}; 1881 + @override 1882 + SearchHistoryEntry map(Map<String, dynamic> data, {String? tablePrefix}) { 1883 + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; 1884 + return SearchHistoryEntry( 1885 + id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, 1886 + query: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}query'])!, 1887 + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, 1888 + searchedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}searched_at'])!, 1889 + accountDid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}account_did'])!, 1890 + ); 1891 + } 1892 + 1893 + @override 1894 + $SearchHistoryTable createAlias(String alias) { 1895 + return $SearchHistoryTable(attachedDatabase, alias); 1896 + } 1897 + } 1898 + 1899 + class SearchHistoryEntry extends DataClass implements Insertable<SearchHistoryEntry> { 1900 + final int id; 1901 + final String query; 1902 + final String type; 1903 + final DateTime searchedAt; 1904 + final String accountDid; 1905 + const SearchHistoryEntry({ 1906 + required this.id, 1907 + required this.query, 1908 + required this.type, 1909 + required this.searchedAt, 1910 + required this.accountDid, 1911 + }); 1912 + @override 1913 + Map<String, Expression> toColumns(bool nullToAbsent) { 1914 + final map = <String, Expression>{}; 1915 + map['id'] = Variable<int>(id); 1916 + map['query'] = Variable<String>(query); 1917 + map['type'] = Variable<String>(type); 1918 + map['searched_at'] = Variable<DateTime>(searchedAt); 1919 + map['account_did'] = Variable<String>(accountDid); 1920 + return map; 1921 + } 1922 + 1923 + SearchHistoryCompanion toCompanion(bool nullToAbsent) { 1924 + return SearchHistoryCompanion( 1925 + id: Value(id), 1926 + query: Value(query), 1927 + type: Value(type), 1928 + searchedAt: Value(searchedAt), 1929 + accountDid: Value(accountDid), 1930 + ); 1931 + } 1932 + 1933 + factory SearchHistoryEntry.fromJson(Map<String, dynamic> json, {ValueSerializer? serializer}) { 1934 + serializer ??= driftRuntimeOptions.defaultSerializer; 1935 + return SearchHistoryEntry( 1936 + id: serializer.fromJson<int>(json['id']), 1937 + query: serializer.fromJson<String>(json['query']), 1938 + type: serializer.fromJson<String>(json['type']), 1939 + searchedAt: serializer.fromJson<DateTime>(json['searchedAt']), 1940 + accountDid: serializer.fromJson<String>(json['accountDid']), 1941 + ); 1942 + } 1943 + @override 1944 + Map<String, dynamic> toJson({ValueSerializer? serializer}) { 1945 + serializer ??= driftRuntimeOptions.defaultSerializer; 1946 + return <String, dynamic>{ 1947 + 'id': serializer.toJson<int>(id), 1948 + 'query': serializer.toJson<String>(query), 1949 + 'type': serializer.toJson<String>(type), 1950 + 'searchedAt': serializer.toJson<DateTime>(searchedAt), 1951 + 'accountDid': serializer.toJson<String>(accountDid), 1952 + }; 1953 + } 1954 + 1955 + SearchHistoryEntry copyWith({int? id, String? query, String? type, DateTime? searchedAt, String? accountDid}) => 1956 + SearchHistoryEntry( 1957 + id: id ?? this.id, 1958 + query: query ?? this.query, 1959 + type: type ?? this.type, 1960 + searchedAt: searchedAt ?? this.searchedAt, 1961 + accountDid: accountDid ?? this.accountDid, 1962 + ); 1963 + SearchHistoryEntry copyWithCompanion(SearchHistoryCompanion data) { 1964 + return SearchHistoryEntry( 1965 + id: data.id.present ? data.id.value : this.id, 1966 + query: data.query.present ? data.query.value : this.query, 1967 + type: data.type.present ? data.type.value : this.type, 1968 + searchedAt: data.searchedAt.present ? data.searchedAt.value : this.searchedAt, 1969 + accountDid: data.accountDid.present ? data.accountDid.value : this.accountDid, 1970 + ); 1971 + } 1972 + 1973 + @override 1974 + String toString() { 1975 + return (StringBuffer('SearchHistoryEntry(') 1976 + ..write('id: $id, ') 1977 + ..write('query: $query, ') 1978 + ..write('type: $type, ') 1979 + ..write('searchedAt: $searchedAt, ') 1980 + ..write('accountDid: $accountDid') 1981 + ..write(')')) 1982 + .toString(); 1983 + } 1984 + 1985 + @override 1986 + int get hashCode => Object.hash(id, query, type, searchedAt, accountDid); 1987 + @override 1988 + bool operator ==(Object other) => 1989 + identical(this, other) || 1990 + (other is SearchHistoryEntry && 1991 + other.id == this.id && 1992 + other.query == this.query && 1993 + other.type == this.type && 1994 + other.searchedAt == this.searchedAt && 1995 + other.accountDid == this.accountDid); 1996 + } 1997 + 1998 + class SearchHistoryCompanion extends UpdateCompanion<SearchHistoryEntry> { 1999 + final Value<int> id; 2000 + final Value<String> query; 2001 + final Value<String> type; 2002 + final Value<DateTime> searchedAt; 2003 + final Value<String> accountDid; 2004 + const SearchHistoryCompanion({ 2005 + this.id = const Value.absent(), 2006 + this.query = const Value.absent(), 2007 + this.type = const Value.absent(), 2008 + this.searchedAt = const Value.absent(), 2009 + this.accountDid = const Value.absent(), 2010 + }); 2011 + SearchHistoryCompanion.insert({ 2012 + this.id = const Value.absent(), 2013 + required String query, 2014 + required String type, 2015 + this.searchedAt = const Value.absent(), 2016 + required String accountDid, 2017 + }) : query = Value(query), 2018 + type = Value(type), 2019 + accountDid = Value(accountDid); 2020 + static Insertable<SearchHistoryEntry> custom({ 2021 + Expression<int>? id, 2022 + Expression<String>? query, 2023 + Expression<String>? type, 2024 + Expression<DateTime>? searchedAt, 2025 + Expression<String>? accountDid, 2026 + }) { 2027 + return RawValuesInsertable({ 2028 + if (id != null) 'id': id, 2029 + if (query != null) 'query': query, 2030 + if (type != null) 'type': type, 2031 + if (searchedAt != null) 'searched_at': searchedAt, 2032 + if (accountDid != null) 'account_did': accountDid, 2033 + }); 2034 + } 2035 + 2036 + SearchHistoryCompanion copyWith({ 2037 + Value<int>? id, 2038 + Value<String>? query, 2039 + Value<String>? type, 2040 + Value<DateTime>? searchedAt, 2041 + Value<String>? accountDid, 2042 + }) { 2043 + return SearchHistoryCompanion( 2044 + id: id ?? this.id, 2045 + query: query ?? this.query, 2046 + type: type ?? this.type, 2047 + searchedAt: searchedAt ?? this.searchedAt, 2048 + accountDid: accountDid ?? this.accountDid, 2049 + ); 2050 + } 2051 + 2052 + @override 2053 + Map<String, Expression> toColumns(bool nullToAbsent) { 2054 + final map = <String, Expression>{}; 2055 + if (id.present) { 2056 + map['id'] = Variable<int>(id.value); 2057 + } 2058 + if (query.present) { 2059 + map['query'] = Variable<String>(query.value); 2060 + } 2061 + if (type.present) { 2062 + map['type'] = Variable<String>(type.value); 2063 + } 2064 + if (searchedAt.present) { 2065 + map['searched_at'] = Variable<DateTime>(searchedAt.value); 2066 + } 2067 + if (accountDid.present) { 2068 + map['account_did'] = Variable<String>(accountDid.value); 2069 + } 2070 + return map; 2071 + } 2072 + 2073 + @override 2074 + String toString() { 2075 + return (StringBuffer('SearchHistoryCompanion(') 2076 + ..write('id: $id, ') 2077 + ..write('query: $query, ') 2078 + ..write('type: $type, ') 2079 + ..write('searchedAt: $searchedAt, ') 2080 + ..write('accountDid: $accountDid') 2081 + ..write(')')) 2082 + .toString(); 2083 + } 2084 + } 2085 + 1791 2086 abstract class _$AppDatabase extends GeneratedDatabase { 1792 2087 _$AppDatabase(QueryExecutor e) : super(e); 1793 2088 $AppDatabaseManager get managers => $AppDatabaseManager(this); ··· 1796 2091 late final $CachedPostsTable cachedPosts = $CachedPostsTable(this); 1797 2092 late final $SettingsTable settings = $SettingsTable(this); 1798 2093 late final $SavedFeedsTable savedFeeds = $SavedFeedsTable(this); 2094 + late final $SearchHistoryTable searchHistory = $SearchHistoryTable(this); 1799 2095 @override 1800 2096 Iterable<TableInfo<Table, Object?>> get allTables => allSchemaEntities.whereType<TableInfo<Table, Object?>>(); 1801 2097 @override 1802 - List<DatabaseSchemaEntity> get allSchemaEntities => [accounts, cachedProfiles, cachedPosts, settings, savedFeeds]; 2098 + List<DatabaseSchemaEntity> get allSchemaEntities => [ 2099 + accounts, 2100 + cachedProfiles, 2101 + cachedPosts, 2102 + settings, 2103 + savedFeeds, 2104 + searchHistory, 2105 + ]; 1803 2106 } 1804 2107 1805 2108 typedef $$AccountsTableCreateCompanionBuilder = ··· 2674 2977 SavedFeedEntry, 2675 2978 PrefetchHooks Function() 2676 2979 >; 2980 + typedef $$SearchHistoryTableCreateCompanionBuilder = 2981 + SearchHistoryCompanion Function({ 2982 + Value<int> id, 2983 + required String query, 2984 + required String type, 2985 + Value<DateTime> searchedAt, 2986 + required String accountDid, 2987 + }); 2988 + typedef $$SearchHistoryTableUpdateCompanionBuilder = 2989 + SearchHistoryCompanion Function({ 2990 + Value<int> id, 2991 + Value<String> query, 2992 + Value<String> type, 2993 + Value<DateTime> searchedAt, 2994 + Value<String> accountDid, 2995 + }); 2996 + 2997 + class $$SearchHistoryTableFilterComposer extends Composer<_$AppDatabase, $SearchHistoryTable> { 2998 + $$SearchHistoryTableFilterComposer({ 2999 + required super.$db, 3000 + required super.$table, 3001 + super.joinBuilder, 3002 + super.$addJoinBuilderToRootComposer, 3003 + super.$removeJoinBuilderFromRootComposer, 3004 + }); 3005 + ColumnFilters<int> get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); 3006 + 3007 + ColumnFilters<String> get query => 3008 + $composableBuilder(column: $table.query, builder: (column) => ColumnFilters(column)); 3009 + 3010 + ColumnFilters<String> get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); 3011 + 3012 + ColumnFilters<DateTime> get searchedAt => 3013 + $composableBuilder(column: $table.searchedAt, builder: (column) => ColumnFilters(column)); 3014 + 3015 + ColumnFilters<String> get accountDid => 3016 + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnFilters(column)); 3017 + } 3018 + 3019 + class $$SearchHistoryTableOrderingComposer extends Composer<_$AppDatabase, $SearchHistoryTable> { 3020 + $$SearchHistoryTableOrderingComposer({ 3021 + required super.$db, 3022 + required super.$table, 3023 + super.joinBuilder, 3024 + super.$addJoinBuilderToRootComposer, 3025 + super.$removeJoinBuilderFromRootComposer, 3026 + }); 3027 + ColumnOrderings<int> get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); 3028 + 3029 + ColumnOrderings<String> get query => 3030 + $composableBuilder(column: $table.query, builder: (column) => ColumnOrderings(column)); 3031 + 3032 + ColumnOrderings<String> get type => 3033 + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); 3034 + 3035 + ColumnOrderings<DateTime> get searchedAt => 3036 + $composableBuilder(column: $table.searchedAt, builder: (column) => ColumnOrderings(column)); 3037 + 3038 + ColumnOrderings<String> get accountDid => 3039 + $composableBuilder(column: $table.accountDid, builder: (column) => ColumnOrderings(column)); 3040 + } 3041 + 3042 + class $$SearchHistoryTableAnnotationComposer extends Composer<_$AppDatabase, $SearchHistoryTable> { 3043 + $$SearchHistoryTableAnnotationComposer({ 3044 + required super.$db, 3045 + required super.$table, 3046 + super.joinBuilder, 3047 + super.$addJoinBuilderToRootComposer, 3048 + super.$removeJoinBuilderFromRootComposer, 3049 + }); 3050 + GeneratedColumn<int> get id => $composableBuilder(column: $table.id, builder: (column) => column); 3051 + 3052 + GeneratedColumn<String> get query => $composableBuilder(column: $table.query, builder: (column) => column); 3053 + 3054 + GeneratedColumn<String> get type => $composableBuilder(column: $table.type, builder: (column) => column); 3055 + 3056 + GeneratedColumn<DateTime> get searchedAt => 3057 + $composableBuilder(column: $table.searchedAt, builder: (column) => column); 3058 + 3059 + GeneratedColumn<String> get accountDid => $composableBuilder(column: $table.accountDid, builder: (column) => column); 3060 + } 3061 + 3062 + class $$SearchHistoryTableTableManager 3063 + extends 3064 + RootTableManager< 3065 + _$AppDatabase, 3066 + $SearchHistoryTable, 3067 + SearchHistoryEntry, 3068 + $$SearchHistoryTableFilterComposer, 3069 + $$SearchHistoryTableOrderingComposer, 3070 + $$SearchHistoryTableAnnotationComposer, 3071 + $$SearchHistoryTableCreateCompanionBuilder, 3072 + $$SearchHistoryTableUpdateCompanionBuilder, 3073 + (SearchHistoryEntry, BaseReferences<_$AppDatabase, $SearchHistoryTable, SearchHistoryEntry>), 3074 + SearchHistoryEntry, 3075 + PrefetchHooks Function() 3076 + > { 3077 + $$SearchHistoryTableTableManager(_$AppDatabase db, $SearchHistoryTable table) 3078 + : super( 3079 + TableManagerState( 3080 + db: db, 3081 + table: table, 3082 + createFilteringComposer: () => $$SearchHistoryTableFilterComposer($db: db, $table: table), 3083 + createOrderingComposer: () => $$SearchHistoryTableOrderingComposer($db: db, $table: table), 3084 + createComputedFieldComposer: () => $$SearchHistoryTableAnnotationComposer($db: db, $table: table), 3085 + updateCompanionCallback: 3086 + ({ 3087 + Value<int> id = const Value.absent(), 3088 + Value<String> query = const Value.absent(), 3089 + Value<String> type = const Value.absent(), 3090 + Value<DateTime> searchedAt = const Value.absent(), 3091 + Value<String> accountDid = const Value.absent(), 3092 + }) => SearchHistoryCompanion( 3093 + id: id, 3094 + query: query, 3095 + type: type, 3096 + searchedAt: searchedAt, 3097 + accountDid: accountDid, 3098 + ), 3099 + createCompanionCallback: 3100 + ({ 3101 + Value<int> id = const Value.absent(), 3102 + required String query, 3103 + required String type, 3104 + Value<DateTime> searchedAt = const Value.absent(), 3105 + required String accountDid, 3106 + }) => SearchHistoryCompanion.insert( 3107 + id: id, 3108 + query: query, 3109 + type: type, 3110 + searchedAt: searchedAt, 3111 + accountDid: accountDid, 3112 + ), 3113 + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), 3114 + prefetchHooksCallback: null, 3115 + ), 3116 + ); 3117 + } 3118 + 3119 + typedef $$SearchHistoryTableProcessedTableManager = 3120 + ProcessedTableManager< 3121 + _$AppDatabase, 3122 + $SearchHistoryTable, 3123 + SearchHistoryEntry, 3124 + $$SearchHistoryTableFilterComposer, 3125 + $$SearchHistoryTableOrderingComposer, 3126 + $$SearchHistoryTableAnnotationComposer, 3127 + $$SearchHistoryTableCreateCompanionBuilder, 3128 + $$SearchHistoryTableUpdateCompanionBuilder, 3129 + (SearchHistoryEntry, BaseReferences<_$AppDatabase, $SearchHistoryTable, SearchHistoryEntry>), 3130 + SearchHistoryEntry, 3131 + PrefetchHooks Function() 3132 + >; 2677 3133 2678 3134 class $AppDatabaseManager { 2679 3135 final _$AppDatabase _db; ··· 2683 3139 $$CachedPostsTableTableManager get cachedPosts => $$CachedPostsTableTableManager(_db, _db.cachedPosts); 2684 3140 $$SettingsTableTableManager get settings => $$SettingsTableTableManager(_db, _db.settings); 2685 3141 $$SavedFeedsTableTableManager get savedFeeds => $$SavedFeedsTableTableManager(_db, _db.savedFeeds); 3142 + $$SearchHistoryTableTableManager get searchHistory => $$SearchHistoryTableTableManager(_db, _db.searchHistory); 2686 3143 }
+9
lib/core/database/tables.dart
··· 65 65 @override 66 66 Set<Column> get primaryKey => {id, accountDid}; 67 67 } 68 + 69 + @DataClassName('SearchHistoryEntry') 70 + class SearchHistory extends Table { 71 + IntColumn get id => integer().autoIncrement()(); 72 + TextColumn get query => text()(); 73 + TextColumn get type => text()(); 74 + DateTimeColumn get searchedAt => dateTime().withDefault(currentDateAndTime)(); 75 + TextColumn get accountDid => text()(); 76 + }
+6
lib/core/router/app_router.dart
··· 10 10 import 'package:lazurite/features/feed/presentation/home_feed_screen.dart'; 11 11 import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 12 12 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 13 + import 'package:lazurite/features/search/presentation/search_screen.dart'; 13 14 import 'package:lazurite/features/settings/presentation/about_screen.dart'; 14 15 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 15 16 ··· 19 20 final NavigatorObserver? navigatorObserver; 20 21 final GlobalKey<NavigatorState> _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root'); 21 22 final GlobalKey<NavigatorState> _homeNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'home'); 23 + final GlobalKey<NavigatorState> _searchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'search'); 22 24 final GlobalKey<NavigatorState> _profileNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'profile'); 23 25 final GlobalKey<NavigatorState> _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings'); 24 26 ··· 54 56 routes: [GoRoute(path: 'feeds', builder: (context, state) => const FeedManagementScreen())], 55 57 ), 56 58 ], 59 + ), 60 + StatefulShellBranch( 61 + navigatorKey: _searchNavigatorKey, 62 + routes: [GoRoute(path: '/search', builder: (context, state) => const SearchScreen())], 57 63 ), 58 64 StatefulShellBranch( 59 65 navigatorKey: _profileNavigatorKey,
+1
lib/core/router/app_shell.dart
··· 24 24 25 25 List<Widget> get _destinations => const [ 26 26 NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), 27 + NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), 27 28 NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 28 29 NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), 29 30 ];
+3 -1
lib/features/devtools/cubit/dev_tools_state.dart
··· 106 106 : selectedCollection as String?, 107 107 records: identical(records, _devToolsStateNoChange) ? this.records : records as List<RepoListRecordsRecord>?, 108 108 recordsCursor: identical(recordsCursor, _devToolsStateNoChange) ? this.recordsCursor : recordsCursor as String?, 109 - selectedRecord: identical(selectedRecord, _devToolsStateNoChange) ? this.selectedRecord : selectedRecord as RecordInfo?, 109 + selectedRecord: identical(selectedRecord, _devToolsStateNoChange) 110 + ? this.selectedRecord 111 + : selectedRecord as RecordInfo?, 110 112 errorMessage: identical(errorMessage, _devToolsStateNoChange) ? this.errorMessage : errorMessage as String?, 111 113 ); 112 114 }
+267
lib/features/search/bloc/search_bloc.dart
··· 1 + import 'dart:async'; 2 + 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:equatable/equatable.dart'; 6 + import 'package:flutter_bloc/flutter_bloc.dart'; 7 + import 'package:lazurite/core/database/app_database.dart'; 8 + import 'package:lazurite/features/search/data/search_repository.dart'; 9 + 10 + part 'search_state.dart'; 11 + 12 + class SearchBloc extends Bloc<SearchEvent, SearchState> { 13 + SearchBloc({required SearchRepository searchRepository, required AppDatabase database, required String accountDid}) 14 + : _searchRepository = searchRepository, 15 + _database = database, 16 + _accountDid = accountDid, 17 + super(const SearchState.initial()) { 18 + on<QuerySubmitted>(_onQuerySubmitted); 19 + on<SearchTabChanged>(_onSearchTabChanged); 20 + on<SearchSortChanged>(_onSearchSortChanged); 21 + on<LoadMoreRequested>(_onLoadMoreRequested); 22 + on<TypeaheadRequested>(_onTypeaheadRequested); 23 + on<TypeaheadResultsLoaded>(_onTypeaheadResultsLoaded); 24 + on<HistoryLoaded>(_onHistoryLoaded); 25 + on<HistoryEntryDeleted>(_onHistoryEntryDeleted); 26 + on<HistoryCleared>(_onHistoryCleared); 27 + on<QueryCleared>(_onQueryCleared); 28 + 29 + add(const HistoryLoaded()); 30 + } 31 + 32 + final SearchRepository _searchRepository; 33 + final AppDatabase _database; 34 + final String _accountDid; 35 + Timer? _debounceTimer; 36 + 37 + Future<void> _onQuerySubmitted(QuerySubmitted event, Emitter<SearchState> emit) async { 38 + final query = event.query.trim(); 39 + if (query.isEmpty) { 40 + emit(const SearchState.initial()); 41 + add(const HistoryLoaded()); 42 + return; 43 + } 44 + 45 + final currentTab = state.currentTab; 46 + final currentSort = state.currentSort; 47 + 48 + if (currentTab == SearchTab.posts) { 49 + emit(SearchState.loadingPosts(query: query, sort: currentSort)); 50 + 51 + try { 52 + final result = await _searchRepository.searchPosts(query: query, sort: currentSort, limit: 50); 53 + await _database.addSearchHistoryEntry(query: query, type: 'posts', accountDid: _accountDid); 54 + final history = await _database.getSearchHistory(_accountDid, limit: 50); 55 + 56 + emit( 57 + SearchState.loadedPosts( 58 + query: query, 59 + sort: currentSort, 60 + posts: result.posts, 61 + cursor: result.cursor, 62 + hitsTotal: result.hitsTotal, 63 + ).copyWith(searchHistory: history), 64 + ); 65 + } catch (error) { 66 + emit(SearchState.error(query: query, message: 'Failed to search posts: $error')); 67 + } 68 + } else { 69 + emit(SearchState.loadingActors(query: query)); 70 + 71 + try { 72 + final result = await _searchRepository.searchActors(query: query, limit: 50); 73 + await _database.addSearchHistoryEntry(query: query, type: 'actors', accountDid: _accountDid); 74 + final history = await _database.getSearchHistory(_accountDid, limit: 50); 75 + 76 + emit( 77 + SearchState.loadedActors( 78 + query: query, 79 + actors: result.actors, 80 + cursor: result.cursor, 81 + ).copyWith(searchHistory: history), 82 + ); 83 + } catch (error) { 84 + emit(SearchState.error(query: query, message: 'Failed to search actors: $error')); 85 + } 86 + } 87 + } 88 + 89 + Future<void> _onSearchTabChanged(SearchTabChanged event, Emitter<SearchState> emit) async { 90 + if (state.currentTab == event.tab) return; 91 + 92 + emit(state.copyWith(currentTab: event.tab)); 93 + 94 + if (state.query.isNotEmpty) { 95 + add(QuerySubmitted(query: state.query)); 96 + } 97 + } 98 + 99 + Future<void> _onSearchSortChanged(SearchSortChanged event, Emitter<SearchState> emit) async { 100 + if (state.currentSort == event.sort) return; 101 + 102 + emit(state.copyWith(currentSort: event.sort)); 103 + 104 + if (state.query.isNotEmpty && state.currentTab == SearchTab.posts) { 105 + add(QuerySubmitted(query: state.query)); 106 + } 107 + } 108 + 109 + Future<void> _onLoadMoreRequested(LoadMoreRequested event, Emitter<SearchState> emit) async { 110 + if (state.isLoadingMore || state.cursor == null) return; 111 + 112 + emit(state.copyWith(isLoadingMore: true)); 113 + 114 + try { 115 + if (state.currentTab == SearchTab.posts) { 116 + final result = await _searchRepository.searchPosts( 117 + query: state.query, 118 + sort: state.currentSort, 119 + cursor: state.cursor, 120 + limit: 50, 121 + ); 122 + 123 + emit(state.copyWith(posts: [...state.posts, ...result.posts], cursor: result.cursor, isLoadingMore: false)); 124 + } else { 125 + final result = await _searchRepository.searchActors(query: state.query, cursor: state.cursor, limit: 50); 126 + 127 + emit(state.copyWith(actors: [...state.actors, ...result.actors], cursor: result.cursor, isLoadingMore: false)); 128 + } 129 + } catch (error) { 130 + emit(state.copyWith(isLoadingMore: false)); 131 + } 132 + } 133 + 134 + Future<void> _onTypeaheadRequested(TypeaheadRequested event, Emitter<SearchState> emit) async { 135 + final query = event.query.trim(); 136 + 137 + _debounceTimer?.cancel(); 138 + 139 + if (!query.startsWith('@')) { 140 + emit(state.copyWith(typeaheadActors: [])); 141 + return; 142 + } 143 + 144 + final handleQuery = query.substring(1).trim(); 145 + if (handleQuery.isEmpty) { 146 + emit(state.copyWith(typeaheadActors: [])); 147 + return; 148 + } 149 + 150 + _debounceTimer = Timer(const Duration(milliseconds: 300), () { 151 + add(TypeaheadResultsLoaded(query: handleQuery)); 152 + }); 153 + } 154 + 155 + Future<void> _onTypeaheadResultsLoaded(TypeaheadResultsLoaded event, Emitter<SearchState> emit) async { 156 + try { 157 + final actors = await _searchRepository.searchActorsTypeahead(query: event.query, limit: 5); 158 + emit(state.copyWith(typeaheadActors: actors)); 159 + } catch (_) { 160 + emit(state.copyWith(typeaheadActors: [])); 161 + } 162 + } 163 + 164 + Future<void> _onHistoryLoaded(HistoryLoaded event, Emitter<SearchState> emit) async { 165 + final entries = await _database.getSearchHistory(_accountDid, limit: 50); 166 + emit(state.copyWith(searchHistory: entries)); 167 + } 168 + 169 + Future<void> _onHistoryEntryDeleted(HistoryEntryDeleted event, Emitter<SearchState> emit) async { 170 + await _database.deleteSearchHistoryEntry(event.id); 171 + final entries = await _database.getSearchHistory(_accountDid, limit: 50); 172 + emit(state.copyWith(searchHistory: entries)); 173 + } 174 + 175 + Future<void> _onHistoryCleared(HistoryCleared event, Emitter<SearchState> emit) async { 176 + await _database.clearSearchHistory(_accountDid); 177 + emit(state.copyWith(searchHistory: [])); 178 + } 179 + 180 + void _onQueryCleared(QueryCleared event, Emitter<SearchState> emit) { 181 + emit(const SearchState.initial()); 182 + add(const HistoryLoaded()); 183 + } 184 + 185 + @override 186 + Future<void> close() { 187 + _debounceTimer?.cancel(); 188 + return super.close(); 189 + } 190 + } 191 + 192 + abstract class SearchEvent extends Equatable { 193 + const SearchEvent(); 194 + 195 + @override 196 + List<Object?> get props => []; 197 + } 198 + 199 + class QuerySubmitted extends SearchEvent { 200 + const QuerySubmitted({required this.query}); 201 + 202 + final String query; 203 + 204 + @override 205 + List<Object?> get props => [query]; 206 + } 207 + 208 + class SearchTabChanged extends SearchEvent { 209 + const SearchTabChanged({required this.tab}); 210 + 211 + final SearchTab tab; 212 + 213 + @override 214 + List<Object?> get props => [tab]; 215 + } 216 + 217 + class SearchSortChanged extends SearchEvent { 218 + const SearchSortChanged({required this.sort}); 219 + 220 + final String sort; 221 + 222 + @override 223 + List<Object?> get props => [sort]; 224 + } 225 + 226 + class LoadMoreRequested extends SearchEvent { 227 + const LoadMoreRequested(); 228 + } 229 + 230 + class TypeaheadRequested extends SearchEvent { 231 + const TypeaheadRequested({required this.query}); 232 + 233 + final String query; 234 + 235 + @override 236 + List<Object?> get props => [query]; 237 + } 238 + 239 + class TypeaheadResultsLoaded extends SearchEvent { 240 + const TypeaheadResultsLoaded({required this.query}); 241 + 242 + final String query; 243 + 244 + @override 245 + List<Object?> get props => [query]; 246 + } 247 + 248 + class HistoryLoaded extends SearchEvent { 249 + const HistoryLoaded(); 250 + } 251 + 252 + class HistoryEntryDeleted extends SearchEvent { 253 + const HistoryEntryDeleted({required this.id}); 254 + 255 + final int id; 256 + 257 + @override 258 + List<Object?> get props => [id]; 259 + } 260 + 261 + class HistoryCleared extends SearchEvent { 262 + const HistoryCleared(); 263 + } 264 + 265 + class QueryCleared extends SearchEvent { 266 + const QueryCleared(); 267 + }
+141
lib/features/search/bloc/search_state.dart
··· 1 + part of 'search_bloc.dart'; 2 + 3 + enum SearchTab { posts, actors } 4 + 5 + extension SearchTabLabel on SearchTab { 6 + String get label => switch (this) { 7 + SearchTab.posts => 'Posts', 8 + SearchTab.actors => 'People', 9 + }; 10 + } 11 + 12 + enum SearchSort { 13 + top, 14 + latest; 15 + 16 + factory SearchSort.fromString(String value) { 17 + return switch (value) { 18 + 'top' => SearchSort.top, 19 + _ => SearchSort.latest, 20 + }; 21 + } 22 + } 23 + 24 + extension SearchSortLabel on SearchSort { 25 + String get label => switch (this) { 26 + SearchSort.top => 'Top', 27 + SearchSort.latest => 'Latest', 28 + }; 29 + } 30 + 31 + enum SearchStatus { initial, loading, loaded, error } 32 + 33 + class SearchState extends Equatable { 34 + const SearchState._({ 35 + required this.status, 36 + this.query = '', 37 + this.currentTab = SearchTab.posts, 38 + this.currentSort = 'top', 39 + this.posts = const [], 40 + this.actors = const [], 41 + this.cursor, 42 + this.hitsTotal, 43 + this.errorMessage, 44 + this.isLoadingMore = false, 45 + this.typeaheadActors = const [], 46 + this.searchHistory = const [], 47 + }); 48 + 49 + const SearchState.initial() : this._(status: SearchStatus.initial); 50 + 51 + const SearchState.loadingPosts({required String query, required String sort}) 52 + : this._(status: SearchStatus.loading, query: query, currentSort: sort); 53 + 54 + const SearchState.loadingActors({required String query}) 55 + : this._(status: SearchStatus.loading, query: query, currentTab: SearchTab.actors); 56 + 57 + const SearchState.loadedPosts({ 58 + required String query, 59 + required String sort, 60 + required List<PostView> posts, 61 + String? cursor, 62 + int? hitsTotal, 63 + }) : this._( 64 + status: SearchStatus.loaded, 65 + query: query, 66 + currentSort: sort, 67 + posts: posts, 68 + cursor: cursor, 69 + hitsTotal: hitsTotal, 70 + ); 71 + 72 + const SearchState.loadedActors({required String query, required List<ProfileView> actors, String? cursor}) 73 + : this._(status: SearchStatus.loaded, query: query, currentTab: SearchTab.actors, actors: actors, cursor: cursor); 74 + 75 + const SearchState.error({required String query, required String message}) 76 + : this._(status: SearchStatus.error, query: query, errorMessage: message); 77 + 78 + final SearchStatus status; 79 + final String query; 80 + final SearchTab currentTab; 81 + final String currentSort; 82 + final List<PostView> posts; 83 + final List<ProfileView> actors; 84 + final String? cursor; 85 + final int? hitsTotal; 86 + final String? errorMessage; 87 + final bool isLoadingMore; 88 + final List<ProfileViewBasic> typeaheadActors; 89 + final List<SearchHistoryEntry> searchHistory; 90 + 91 + bool get isLoading => status == SearchStatus.loading; 92 + bool get hasError => status == SearchStatus.error; 93 + bool get hasResults => posts.isNotEmpty || actors.isNotEmpty; 94 + bool get hasMore => cursor != null; 95 + 96 + SearchState copyWith({ 97 + SearchStatus? status, 98 + String? query, 99 + SearchTab? currentTab, 100 + String? currentSort, 101 + List<PostView>? posts, 102 + List<ProfileView>? actors, 103 + String? cursor, 104 + int? hitsTotal, 105 + String? errorMessage, 106 + bool? isLoadingMore, 107 + List<ProfileViewBasic>? typeaheadActors, 108 + List<SearchHistoryEntry>? searchHistory, 109 + }) { 110 + return SearchState._( 111 + status: status ?? this.status, 112 + query: query ?? this.query, 113 + currentTab: currentTab ?? this.currentTab, 114 + currentSort: currentSort ?? this.currentSort, 115 + posts: posts ?? this.posts, 116 + actors: actors ?? this.actors, 117 + cursor: cursor ?? this.cursor, 118 + hitsTotal: hitsTotal ?? this.hitsTotal, 119 + errorMessage: errorMessage ?? this.errorMessage, 120 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 121 + typeaheadActors: typeaheadActors ?? this.typeaheadActors, 122 + searchHistory: searchHistory ?? this.searchHistory, 123 + ); 124 + } 125 + 126 + @override 127 + List<Object?> get props => [ 128 + status, 129 + query, 130 + currentTab, 131 + currentSort, 132 + posts, 133 + actors, 134 + cursor, 135 + hitsTotal, 136 + errorMessage, 137 + isLoadingMore, 138 + typeaheadActors, 139 + searchHistory, 140 + ]; 141 + }
+56
lib/features/search/data/search_repository.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_searchposts.dart'; 4 + import 'package:bluesky/bluesky.dart'; 5 + 6 + class SearchRepository { 7 + SearchRepository({required Bluesky bluesky}) : _bluesky = bluesky; 8 + 9 + final Bluesky _bluesky; 10 + 11 + Future<SearchPostsResult> searchPosts({ 12 + required String query, 13 + String sort = 'top', 14 + String? cursor, 15 + int limit = 50, 16 + }) async { 17 + final sortValue = sort == 'latest' 18 + ? const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.latest) 19 + : const FeedSearchPostsSort.knownValue(data: KnownFeedSearchPostsSort.top); 20 + 21 + final response = await _bluesky.feed.searchPosts(q: query, sort: sortValue, cursor: cursor, limit: limit); 22 + 23 + return SearchPostsResult( 24 + posts: response.data.posts, 25 + cursor: response.data.cursor, 26 + hitsTotal: response.data.hitsTotal, 27 + ); 28 + } 29 + 30 + Future<SearchActorsResult> searchActors({required String query, String? cursor, int limit = 50}) async { 31 + final response = await _bluesky.actor.searchActors(q: query, cursor: cursor, limit: limit); 32 + 33 + return SearchActorsResult(actors: response.data.actors, cursor: response.data.cursor); 34 + } 35 + 36 + Future<List<ProfileViewBasic>> searchActorsTypeahead({required String query, int limit = 10}) async { 37 + final response = await _bluesky.actor.searchActorsTypeahead(q: query, limit: limit); 38 + 39 + return response.data.actors; 40 + } 41 + } 42 + 43 + class SearchPostsResult { 44 + SearchPostsResult({required this.posts, this.cursor, this.hitsTotal}); 45 + 46 + final List<PostView> posts; 47 + final String? cursor; 48 + final int? hitsTotal; 49 + } 50 + 51 + class SearchActorsResult { 52 + SearchActorsResult({required this.actors, this.cursor}); 53 + 54 + final List<ProfileView> actors; 55 + final String? cursor; 56 + }
+796
lib/features/search/presentation/search_screen.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_feed_defs.dart'; 3 + import 'package:bluesky/app_bsky_feed_post.dart'; 4 + import 'package:flutter/material.dart'; 5 + import 'package:flutter_bloc/flutter_bloc.dart'; 6 + import 'package:go_router/go_router.dart'; 7 + import 'package:intl/intl.dart'; 8 + import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 9 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 10 + 11 + class SearchScreen extends StatefulWidget { 12 + const SearchScreen({super.key}); 13 + 14 + @override 15 + State<SearchScreen> createState() => _SearchScreenState(); 16 + } 17 + 18 + class _SearchScreenState extends State<SearchScreen> { 19 + final TextEditingController _searchController = TextEditingController(); 20 + final FocusNode _focusNode = FocusNode(); 21 + final ScrollController _scrollController = ScrollController(); 22 + 23 + @override 24 + void initState() { 25 + super.initState(); 26 + _searchController.addListener(_onSearchChanged); 27 + _scrollController.addListener(_onScroll); 28 + } 29 + 30 + @override 31 + void dispose() { 32 + _searchController.removeListener(_onSearchChanged); 33 + _searchController.dispose(); 34 + _focusNode.dispose(); 35 + _scrollController.dispose(); 36 + super.dispose(); 37 + } 38 + 39 + void _onSearchChanged() { 40 + setState(() {}); 41 + context.read<SearchBloc>().add(TypeaheadRequested(query: _searchController.text)); 42 + } 43 + 44 + void _onScroll() { 45 + if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { 46 + context.read<SearchBloc>().add(const LoadMoreRequested()); 47 + } 48 + } 49 + 50 + void _onSubmit(String query) { 51 + if (query.trim().isEmpty) return; 52 + context.read<SearchBloc>().add(QuerySubmitted(query: query)); 53 + _focusNode.unfocus(); 54 + } 55 + 56 + void _onCancel() { 57 + _searchController.clear(); 58 + setState(() {}); 59 + context.read<SearchBloc>().add(const QueryCleared()); 60 + } 61 + 62 + void _onTabChanged(SearchTab tab) { 63 + context.read<SearchBloc>().add(SearchTabChanged(tab: tab)); 64 + } 65 + 66 + void _onSortChanged(String sort) { 67 + context.read<SearchBloc>().add(SearchSortChanged(sort: sort)); 68 + } 69 + 70 + void _onHistoryTap(String query, String type) { 71 + _searchController.text = query; 72 + final tab = type == 'posts' ? SearchTab.posts : SearchTab.actors; 73 + context.read<SearchBloc>().add(SearchTabChanged(tab: tab)); 74 + context.read<SearchBloc>().add(QuerySubmitted(query: query)); 75 + _focusNode.unfocus(); 76 + } 77 + 78 + void _onHistoryDelete(int id) { 79 + context.read<SearchBloc>().add(HistoryEntryDeleted(id: id)); 80 + } 81 + 82 + void _onClearHistory() { 83 + showDialog( 84 + context: context, 85 + builder: (context) => AlertDialog( 86 + title: const Text('Clear search history?'), 87 + content: const Text('This will delete all your recent searches.'), 88 + actions: [ 89 + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), 90 + TextButton( 91 + onPressed: () { 92 + Navigator.pop(context); 93 + context.read<SearchBloc>().add(const HistoryCleared()); 94 + }, 95 + child: const Text('Clear'), 96 + ), 97 + ], 98 + ), 99 + ); 100 + } 101 + 102 + @override 103 + Widget build(BuildContext context) { 104 + return Scaffold( 105 + body: SafeArea( 106 + child: BlocBuilder<SearchBloc, SearchState>( 107 + builder: (context, state) { 108 + return Column( 109 + children: [ 110 + _buildSearchBar(context, state), 111 + _buildTabs(context, state), 112 + if (state.currentTab == SearchTab.posts && state.hasResults) _buildSortToggle(context, state), 113 + Expanded(child: _buildBody(context, state)), 114 + ], 115 + ); 116 + }, 117 + ), 118 + ), 119 + ); 120 + } 121 + 122 + Widget _buildSearchBar(BuildContext context, SearchState state) { 123 + final hasText = _searchController.text.isNotEmpty; 124 + final theme = Theme.of(context); 125 + return Container( 126 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), 127 + decoration: BoxDecoration( 128 + border: Border(bottom: BorderSide(color: theme.dividerColor)), 129 + ), 130 + child: Row( 131 + children: [ 132 + Expanded( 133 + child: TextField( 134 + controller: _searchController, 135 + focusNode: _focusNode, 136 + onSubmitted: _onSubmit, 137 + textInputAction: TextInputAction.search, 138 + decoration: InputDecoration( 139 + hintText: 'Search posts or people', 140 + prefixIcon: const Icon(Icons.search, size: 20), 141 + suffixIcon: hasText ? _buildSuffixIcon(context, theme) : null, 142 + border: OutlineInputBorder(borderRadius: BorderRadius.circular(999)), 143 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0), 144 + filled: true, 145 + fillColor: theme.colorScheme.surfaceContainerHighest, 146 + enabledBorder: OutlineInputBorder( 147 + borderRadius: BorderRadius.circular(999), 148 + borderSide: BorderSide.none, 149 + ), 150 + focusedBorder: OutlineInputBorder( 151 + borderRadius: BorderRadius.circular(999), 152 + borderSide: BorderSide(color: theme.colorScheme.primary), 153 + ), 154 + ), 155 + ), 156 + ), 157 + ], 158 + ), 159 + ); 160 + } 161 + 162 + Widget _buildSuffixIcon(BuildContext context, ThemeData theme) { 163 + return Row( 164 + mainAxisSize: MainAxisSize.min, 165 + children: [ 166 + IconButton( 167 + onPressed: _onCancel, 168 + icon: const Icon(Icons.close, size: 20), 169 + padding: EdgeInsets.zero, 170 + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), 171 + ), 172 + IconButton( 173 + onPressed: () => _onSubmit(_searchController.text), 174 + icon: Icon(Icons.arrow_forward_rounded, size: 20, color: theme.colorScheme.primary), 175 + padding: EdgeInsets.zero, 176 + constraints: const BoxConstraints(minWidth: 24, minHeight: 24), 177 + ), 178 + ], 179 + ); 180 + } 181 + 182 + Widget _buildTabs(BuildContext context, SearchState state) { 183 + return Container( 184 + decoration: BoxDecoration( 185 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 186 + ), 187 + child: Row(children: [_buildTab(context, SearchTab.posts, state), _buildTab(context, SearchTab.actors, state)]), 188 + ); 189 + } 190 + 191 + Widget _buildTab(BuildContext context, SearchTab tab, SearchState state) { 192 + final isSelected = state.currentTab == tab; 193 + final theme = Theme.of(context); 194 + final textStyle = theme.textTheme.bodyLarge?.copyWith( 195 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, 196 + color: isSelected ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant, 197 + ); 198 + return Expanded( 199 + child: InkWell( 200 + onTap: () => _onTabChanged(tab), 201 + child: Container( 202 + padding: const EdgeInsets.symmetric(vertical: 12), 203 + decoration: BoxDecoration( 204 + border: Border( 205 + bottom: BorderSide(color: isSelected ? theme.colorScheme.primary : Colors.transparent, width: 2), 206 + ), 207 + ), 208 + child: Text(tab.label, textAlign: TextAlign.center, style: textStyle), 209 + ), 210 + ), 211 + ); 212 + } 213 + 214 + Widget _buildSortToggle(BuildContext context, SearchState state) { 215 + final theme = Theme.of(context); 216 + 217 + return Container( 218 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 219 + decoration: BoxDecoration( 220 + border: Border(top: BorderSide(color: theme.dividerColor)), 221 + ), 222 + child: Row( 223 + children: [ 224 + Text('Sort by', style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), 225 + const SizedBox(width: 8), 226 + Container( 227 + decoration: BoxDecoration( 228 + color: theme.colorScheme.surfaceContainerHighest, 229 + borderRadius: BorderRadius.circular(8), 230 + ), 231 + child: Row( 232 + mainAxisSize: MainAxisSize.min, 233 + children: [ 234 + _buildSortOption(context, SearchSort.top, state), 235 + _buildSortOption(context, SearchSort.latest, state), 236 + ], 237 + ), 238 + ), 239 + ], 240 + ), 241 + ); 242 + } 243 + 244 + Widget _buildSortOption(BuildContext context, SearchSort sort, SearchState state) { 245 + final isSelected = state.currentSort == sort.name; 246 + final theme = Theme.of(context); 247 + final labelColor = isSelected ? theme.colorScheme.onPrimary : theme.colorScheme.onSurfaceVariant; 248 + 249 + return GestureDetector( 250 + onTap: () => _onSortChanged(sort.name), 251 + child: Container( 252 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), 253 + decoration: BoxDecoration( 254 + color: isSelected ? theme.colorScheme.primary : null, 255 + borderRadius: BorderRadius.circular(8), 256 + ), 257 + child: Text( 258 + sort.label, 259 + style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500, color: labelColor), 260 + ), 261 + ), 262 + ); 263 + } 264 + 265 + Widget _buildBody(BuildContext context, SearchState state) { 266 + if (state.typeaheadActors.isNotEmpty && _searchController.text.startsWith('@')) { 267 + return _buildTypeaheadResults(context, state); 268 + } 269 + 270 + if (state.query.isEmpty) { 271 + return _buildSearchHistory(context, state); 272 + } 273 + 274 + if (state.isLoading) { 275 + return const Center(child: CircularProgressIndicator()); 276 + } 277 + 278 + if (state.hasError) { 279 + return Center( 280 + child: Column( 281 + mainAxisAlignment: MainAxisAlignment.center, 282 + children: [ 283 + Text('Search failed', style: Theme.of(context).textTheme.titleMedium), 284 + const SizedBox(height: 8), 285 + Text( 286 + state.errorMessage ?? 'Unknown error', 287 + textAlign: TextAlign.center, 288 + style: Theme.of(context).textTheme.bodySmall, 289 + ), 290 + const SizedBox(height: 16), 291 + FilledButton( 292 + onPressed: () => context.read<SearchBloc>().add(QuerySubmitted(query: state.query)), 293 + child: const Text('Retry'), 294 + ), 295 + ], 296 + ), 297 + ); 298 + } 299 + 300 + if (state.currentTab == SearchTab.posts) { 301 + return _buildPostResults(context, state); 302 + } else { 303 + return _buildActorResults(context, state); 304 + } 305 + } 306 + 307 + Widget _buildSearchHistory(BuildContext context, SearchState state) { 308 + final history = state.searchHistory; 309 + if (history.isEmpty) { 310 + return const _SearchEmptyState(); 311 + } 312 + 313 + return Column( 314 + children: [ 315 + Container( 316 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 317 + child: Row( 318 + mainAxisAlignment: MainAxisAlignment.spaceBetween, 319 + children: [ 320 + Text('Recent Searches', style: Theme.of(context).textTheme.titleSmall), 321 + TextButton(onPressed: _onClearHistory, child: const Text('Clear All')), 322 + ], 323 + ), 324 + ), 325 + Expanded( 326 + child: ListView.builder( 327 + itemCount: history.length, 328 + itemBuilder: (context, index) { 329 + final entry = history[index]; 330 + final label = entry.type == 'posts' ? 'Posts' : 'People'; 331 + return Dismissible( 332 + key: Key('history_${entry.id}'), 333 + direction: DismissDirection.endToStart, 334 + onDismissed: (_) => _onHistoryDelete(entry.id), 335 + background: Container( 336 + alignment: Alignment.centerRight, 337 + padding: const EdgeInsets.only(right: 16), 338 + color: Theme.of(context).colorScheme.error, 339 + child: Icon(Icons.delete, color: Theme.of(context).colorScheme.onError), 340 + ), 341 + child: ListTile( 342 + leading: const Icon(Icons.history), 343 + title: Text(entry.query), 344 + subtitle: Text('$label · ${_formatHistoryTime(entry.searchedAt)}'), 345 + onTap: () => _onHistoryTap(entry.query, entry.type), 346 + ), 347 + ); 348 + }, 349 + ), 350 + ), 351 + ], 352 + ); 353 + } 354 + 355 + Widget _buildTypeaheadResults(BuildContext context, SearchState state) { 356 + return ListView.builder( 357 + itemCount: state.typeaheadActors.length, 358 + itemBuilder: (context, index) { 359 + final actor = state.typeaheadActors[index]; 360 + return _ActorListTile( 361 + actor: actor, 362 + onTap: () { 363 + _searchController.text = '@${actor.handle}'; 364 + _onSubmit('@${actor.handle}'); 365 + }, 366 + ); 367 + }, 368 + ); 369 + } 370 + 371 + Widget _buildPostResults(BuildContext context, SearchState state) { 372 + final posts = state.posts; 373 + if (posts.isEmpty) { 374 + return Center(child: Text('No posts found', style: Theme.of(context).textTheme.bodyLarge)); 375 + } 376 + 377 + return ListView.builder( 378 + controller: _scrollController, 379 + itemCount: posts.length + (state.isLoadingMore ? 1 : 0), 380 + itemBuilder: (context, index) { 381 + if (index == posts.length) { 382 + return const Center( 383 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 384 + ); 385 + } 386 + return _PostViewCard(post: posts[index]); 387 + }, 388 + ); 389 + } 390 + 391 + Widget _buildActorResults(BuildContext context, SearchState state) { 392 + final actors = state.actors; 393 + if (actors.isEmpty) { 394 + return Center(child: Text('No people found', style: Theme.of(context).textTheme.bodyLarge)); 395 + } 396 + 397 + return ListView.builder( 398 + controller: _scrollController, 399 + itemCount: actors.length + (state.isLoadingMore ? 1 : 0), 400 + itemBuilder: (context, index) { 401 + if (index == actors.length) { 402 + return const Center( 403 + child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()), 404 + ); 405 + } 406 + return _ActorResultTile(actor: actors[index]); 407 + }, 408 + ); 409 + } 410 + 411 + String _formatHistoryTime(DateTime time) { 412 + final now = DateTime.now(); 413 + final difference = now.difference(time); 414 + 415 + if (difference.inMinutes < 1) { 416 + return 'Just now'; 417 + } 418 + if (difference.inHours < 1) { 419 + return '${difference.inMinutes}m ago'; 420 + } 421 + if (difference.inDays < 1) { 422 + return '${difference.inHours}h ago'; 423 + } 424 + if (difference.inDays < 7) { 425 + return '${difference.inDays}d ago'; 426 + } 427 + return DateFormat('MMM d').format(time); 428 + } 429 + } 430 + 431 + class _PostViewCard extends StatelessWidget { 432 + const _PostViewCard({required this.post}); 433 + 434 + final PostView post; 435 + 436 + @override 437 + Widget build(BuildContext context) { 438 + final record = _tryParseRecord(post.record); 439 + final createdAt = record?.createdAt ?? post.indexedAt; 440 + 441 + return Card( 442 + margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 443 + elevation: 0, 444 + shape: const RoundedRectangleBorder(), 445 + child: Padding( 446 + padding: const EdgeInsets.all(16), 447 + child: Column( 448 + crossAxisAlignment: CrossAxisAlignment.start, 449 + children: [ 450 + _buildHeader(context, post.author, createdAt), 451 + if (record != null && record.text.isNotEmpty) ...[ 452 + const SizedBox(height: 12), 453 + FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 454 + ], 455 + const SizedBox(height: 12), 456 + _buildActions(context), 457 + ], 458 + ), 459 + ), 460 + ); 461 + } 462 + 463 + Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 464 + return InkWell( 465 + onTap: () => _navigateToProfile(context, author.did), 466 + child: Row( 467 + crossAxisAlignment: CrossAxisAlignment.start, 468 + children: [ 469 + CircleAvatar( 470 + radius: 22, 471 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 472 + backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, 473 + child: author.avatar == null 474 + ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) 475 + : null, 476 + ), 477 + const SizedBox(width: 12), 478 + Expanded( 479 + child: Column( 480 + crossAxisAlignment: CrossAxisAlignment.start, 481 + children: [ 482 + Text( 483 + author.displayName ?? author.handle, 484 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 485 + maxLines: 1, 486 + overflow: TextOverflow.ellipsis, 487 + ), 488 + const SizedBox(height: 2), 489 + Text( 490 + '@${author.handle} · ${_formatTime(createdAt)}', 491 + style: Theme.of( 492 + context, 493 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 494 + ), 495 + ], 496 + ), 497 + ), 498 + ], 499 + ), 500 + ); 501 + } 502 + 503 + Widget _buildActions(BuildContext context) { 504 + return Row( 505 + mainAxisAlignment: MainAxisAlignment.spaceAround, 506 + children: [ 507 + _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'), 508 + _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'), 509 + _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'), 510 + _buildActionButton(context, Icons.share_outlined, ''), 511 + ], 512 + ); 513 + } 514 + 515 + Widget _buildActionButton(BuildContext context, IconData icon, String count) { 516 + final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; 517 + 518 + return InkWell( 519 + onTap: () {}, 520 + borderRadius: BorderRadius.circular(999), 521 + child: Padding( 522 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 523 + child: Row( 524 + children: [ 525 + Icon(icon, size: 18, color: iconColor), 526 + if (count.isNotEmpty) ...[ 527 + const SizedBox(width: 4), 528 + Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 529 + ], 530 + ], 531 + ), 532 + ), 533 + ); 534 + } 535 + 536 + void _navigateToProfile(BuildContext context, String did) { 537 + final router = GoRouter.maybeOf(context); 538 + if (router != null) { 539 + router.push('/profile/view?actor=${Uri.encodeQueryComponent(did)}'); 540 + } 541 + } 542 + 543 + FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 544 + try { 545 + return FeedPostRecord.fromJson(record); 546 + } catch (_) { 547 + return null; 548 + } 549 + } 550 + 551 + String _formatTime(DateTime time) { 552 + final now = DateTime.now(); 553 + final difference = now.difference(time); 554 + 555 + if (difference.inMinutes < 1) { 556 + return 'now'; 557 + } 558 + if (difference.inHours < 1) { 559 + return '${difference.inMinutes}m'; 560 + } 561 + if (difference.inDays < 1) { 562 + return '${difference.inHours}h'; 563 + } 564 + if (difference.inDays < 7) { 565 + return '${difference.inDays}d'; 566 + } 567 + return DateFormat('MMM d').format(time); 568 + } 569 + 570 + String _initials(String value) { 571 + final parts = value.trim().split(RegExp(r'\s+')); 572 + if (parts.isEmpty || parts.first.isEmpty) { 573 + return '?'; 574 + } 575 + if (parts.length == 1) { 576 + return parts.first.substring(0, 1).toUpperCase(); 577 + } 578 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 579 + } 580 + } 581 + 582 + class _ActorResultTile extends StatelessWidget { 583 + const _ActorResultTile({required this.actor}); 584 + 585 + final ProfileView actor; 586 + 587 + @override 588 + Widget build(BuildContext context) { 589 + return InkWell( 590 + onTap: () => _navigateToProfile(context, actor.did), 591 + child: Container( 592 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 593 + decoration: BoxDecoration( 594 + border: Border(bottom: BorderSide(color: Theme.of(context).dividerColor)), 595 + ), 596 + child: Row( 597 + children: [ 598 + CircleAvatar( 599 + radius: 24, 600 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 601 + backgroundImage: actor.avatar != null ? NetworkImage(actor.avatar!) : null, 602 + child: actor.avatar == null 603 + ? Text(_initials(actor.displayName ?? actor.handle), style: Theme.of(context).textTheme.labelLarge) 604 + : null, 605 + ), 606 + const SizedBox(width: 12), 607 + Expanded( 608 + child: Column( 609 + crossAxisAlignment: CrossAxisAlignment.start, 610 + children: [ 611 + Text( 612 + actor.displayName ?? actor.handle, 613 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), 614 + maxLines: 1, 615 + overflow: TextOverflow.ellipsis, 616 + ), 617 + Text( 618 + '@${actor.handle}', 619 + style: Theme.of( 620 + context, 621 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 622 + ), 623 + if (actor.description != null && actor.description!.isNotEmpty) ...[ 624 + const SizedBox(height: 2), 625 + Text( 626 + actor.description!, 627 + maxLines: 1, 628 + overflow: TextOverflow.ellipsis, 629 + style: Theme.of( 630 + context, 631 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 632 + ), 633 + ], 634 + ], 635 + ), 636 + ), 637 + const SizedBox(width: 8), 638 + _FollowButton(actor: actor), 639 + ], 640 + ), 641 + ), 642 + ); 643 + } 644 + 645 + void _navigateToProfile(BuildContext context, String did) { 646 + final router = GoRouter.maybeOf(context); 647 + if (router != null) { 648 + router.push('/profile/view?actor=${Uri.encodeQueryComponent(did)}'); 649 + } 650 + } 651 + 652 + String _initials(String value) { 653 + final parts = value.trim().split(RegExp(r'\s+')); 654 + if (parts.isEmpty || parts.first.isEmpty) { 655 + return '?'; 656 + } 657 + if (parts.length == 1) { 658 + return parts.first.substring(0, 1).toUpperCase(); 659 + } 660 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 661 + } 662 + } 663 + 664 + class _ActorListTile extends StatelessWidget { 665 + const _ActorListTile({required this.actor, required this.onTap}); 666 + 667 + final ProfileViewBasic actor; 668 + final VoidCallback onTap; 669 + 670 + @override 671 + Widget build(BuildContext context) { 672 + return InkWell( 673 + onTap: onTap, 674 + child: Container( 675 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), 676 + child: Row( 677 + children: [ 678 + CircleAvatar( 679 + radius: 20, 680 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 681 + backgroundImage: actor.avatar != null ? NetworkImage(actor.avatar!) : null, 682 + child: actor.avatar == null 683 + ? Text(_initials(actor.displayName ?? actor.handle), style: Theme.of(context).textTheme.labelMedium) 684 + : null, 685 + ), 686 + const SizedBox(width: 12), 687 + Expanded( 688 + child: Column( 689 + crossAxisAlignment: CrossAxisAlignment.start, 690 + children: [ 691 + Text( 692 + actor.displayName ?? actor.handle, 693 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), 694 + maxLines: 1, 695 + overflow: TextOverflow.ellipsis, 696 + ), 697 + Text( 698 + '@${actor.handle}', 699 + style: Theme.of( 700 + context, 701 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 702 + ), 703 + ], 704 + ), 705 + ), 706 + ], 707 + ), 708 + ), 709 + ); 710 + } 711 + 712 + String _initials(String value) { 713 + final parts = value.trim().split(RegExp(r'\s+')); 714 + if (parts.isEmpty || parts.first.isEmpty) { 715 + return '?'; 716 + } 717 + if (parts.length == 1) { 718 + return parts.first.substring(0, 1).toUpperCase(); 719 + } 720 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 721 + } 722 + } 723 + 724 + class _FollowButton extends StatefulWidget { 725 + const _FollowButton({required this.actor}); 726 + 727 + final ProfileView actor; 728 + 729 + @override 730 + State<_FollowButton> createState() => _FollowButtonState(); 731 + } 732 + 733 + class _FollowButtonState extends State<_FollowButton> { 734 + late bool _isFollowing; 735 + 736 + @override 737 + void initState() { 738 + super.initState(); 739 + _isFollowing = widget.actor.viewer?.following != null; 740 + } 741 + 742 + @override 743 + Widget build(BuildContext context) { 744 + if (_isFollowing) { 745 + return OutlinedButton( 746 + onPressed: _toggleFollow, 747 + style: OutlinedButton.styleFrom( 748 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 749 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 750 + ), 751 + child: const Text('Following'), 752 + ); 753 + } 754 + 755 + return FilledButton.tonal( 756 + onPressed: _toggleFollow, 757 + style: FilledButton.styleFrom( 758 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), 759 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), 760 + ), 761 + child: const Text('Follow'), 762 + ); 763 + } 764 + 765 + void _toggleFollow() { 766 + setState(() => _isFollowing = !_isFollowing); 767 + } 768 + } 769 + 770 + class _SearchEmptyState extends StatelessWidget { 771 + const _SearchEmptyState(); 772 + 773 + @override 774 + Widget build(BuildContext context) { 775 + return Center( 776 + child: Padding( 777 + padding: const EdgeInsets.all(24), 778 + child: Column( 779 + mainAxisSize: MainAxisSize.min, 780 + children: [ 781 + Icon(Icons.search, size: 64, color: Theme.of(context).colorScheme.outline), 782 + const SizedBox(height: 16), 783 + Text('Search', style: Theme.of(context).textTheme.titleMedium), 784 + const SizedBox(height: 8), 785 + Text( 786 + 'Find posts and people on the network.\n' 787 + 'Type @ to search for users.', 788 + textAlign: TextAlign.center, 789 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), 790 + ), 791 + ], 792 + ), 793 + ), 794 + ); 795 + } 796 + }
+8
lib/main.dart
··· 17 17 import 'package:lazurite/features/feed/data/feed_repository.dart'; 18 18 import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 19 19 import 'package:lazurite/features/profile/data/profile_repository.dart'; 20 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 21 + import 'package:lazurite/features/search/data/search_repository.dart'; 20 22 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 21 23 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 22 24 ··· 114 116 } 115 117 116 118 final feedRepository = FeedRepository(bluesky: bluesky); 119 + final searchRepository = SearchRepository(bluesky: bluesky); 117 120 final accountDid = authState.tokens?.did ?? ''; 118 121 119 122 return MultiBlocProvider( ··· 132 135 )..loadPreferences(), 133 136 ), 134 137 BlocProvider(create: (_) => DevToolsCubit(atproto: bluesky.atproto)), 138 + BlocProvider( 139 + create: (_) => 140 + SearchBloc(searchRepository: searchRepository, database: widget.database, accountDid: accountDid), 141 + ), 135 142 RepositoryProvider.value(value: feedRepository), 143 + RepositoryProvider.value(value: searchRepository), 136 144 ], 137 145 child: appShell, 138 146 );
+125
test/features/search/presentation/search_screen_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/features/search/bloc/search_bloc.dart'; 6 + import 'package:lazurite/features/search/data/search_repository.dart'; 7 + import 'package:lazurite/features/search/presentation/search_screen.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockSearchRepository extends Mock implements SearchRepository {} 11 + 12 + class MockAppDatabase extends Mock implements AppDatabase {} 13 + 14 + void main() { 15 + group('SearchScreen', () { 16 + late MockSearchRepository mockSearchRepository; 17 + late MockAppDatabase mockDatabase; 18 + 19 + setUp(() { 20 + mockSearchRepository = MockSearchRepository(); 21 + mockDatabase = MockAppDatabase(); 22 + when(() => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit'))).thenAnswer((_) async => []); 23 + when( 24 + () => mockSearchRepository.searchPosts( 25 + query: any(named: 'query'), 26 + sort: any(named: 'sort'), 27 + cursor: any(named: 'cursor'), 28 + limit: any(named: 'limit'), 29 + ), 30 + ).thenAnswer((_) async => SearchPostsResult(posts: [])); 31 + when( 32 + () => mockSearchRepository.searchActors( 33 + query: any(named: 'query'), 34 + cursor: any(named: 'cursor'), 35 + limit: any(named: 'limit'), 36 + ), 37 + ).thenAnswer((_) async => SearchActorsResult(actors: [])); 38 + when( 39 + () => mockSearchRepository.searchActorsTypeahead( 40 + query: any(named: 'query'), 41 + limit: any(named: 'limit'), 42 + ), 43 + ).thenAnswer((_) async => []); 44 + }); 45 + 46 + Widget buildSubject() { 47 + return MaterialApp( 48 + home: BlocProvider<SearchBloc>( 49 + create: (_) => 50 + SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), 51 + child: const SearchScreen(), 52 + ), 53 + ); 54 + } 55 + 56 + testWidgets('displays search input and tabs', (tester) async { 57 + await tester.pumpWidget(buildSubject()); 58 + await tester.pumpAndSettle(); 59 + 60 + expect(find.text('Search posts or people'), findsOneWidget); 61 + expect(find.text('Posts'), findsOneWidget); 62 + expect(find.text('People'), findsOneWidget); 63 + expect(find.text('Top'), findsOneWidget); 64 + expect(find.text('Latest'), findsOneWidget); 65 + }); 66 + 67 + testWidgets('shows empty state when no search history', (tester) async { 68 + await tester.pumpWidget(buildSubject()); 69 + await tester.pumpAndSettle(); 70 + 71 + expect(find.text('Search'), findsOneWidget); 72 + expect(find.textContaining('Find posts and people'), findsOneWidget); 73 + }); 74 + 75 + testWidgets('tab switching works correctly', (tester) async { 76 + await tester.pumpWidget(buildSubject()); 77 + await tester.pumpAndSettle(); 78 + 79 + final peopleTab = find.text('People'); 80 + await tester.tap(peopleTab); 81 + await tester.pumpAndSettle(); 82 + 83 + final postsTab = find.text('Posts'); 84 + await tester.tap(postsTab); 85 + await tester.pumpAndSettle(); 86 + }); 87 + 88 + testWidgets('sort toggle changes correctly', (tester) async { 89 + await tester.pumpWidget(buildSubject()); 90 + await tester.pumpAndSettle(); 91 + 92 + final latestButton = find.text('Latest'); 93 + await tester.tap(latestButton); 94 + await tester.pumpAndSettle(); 95 + }); 96 + 97 + testWidgets('shows search history when available', (tester) async { 98 + final historyEntry = SearchHistoryEntry( 99 + id: 1, 100 + query: 'flutter', 101 + type: 'posts', 102 + searchedAt: DateTime.now(), 103 + accountDid: 'did:plc:test', 104 + ); 105 + 106 + when( 107 + () => mockDatabase.getSearchHistory(any(), limit: any(named: 'limit')), 108 + ).thenAnswer((_) async => [historyEntry]); 109 + 110 + await tester.pumpWidget( 111 + MaterialApp( 112 + home: BlocProvider<SearchBloc>( 113 + create: (_) => 114 + SearchBloc(searchRepository: mockSearchRepository, database: mockDatabase, accountDid: 'did:plc:test'), 115 + child: const SearchScreen(), 116 + ), 117 + ), 118 + ); 119 + await tester.pumpAndSettle(); 120 + 121 + expect(find.text('Recent Searches'), findsOneWidget); 122 + expect(find.text('flutter'), findsOneWidget); 123 + }); 124 + }); 125 + }