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: profile persistence, rendering, and state

+2614 -43
+580
docs/designs/logs.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> 6 + <title>Logs - Lazurite</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" 11 + rel="stylesheet" /> 12 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/geist@1.2.2/dist/fonts/geist-sans/style.css" /> 13 + <link rel="stylesheet" href="styles.css" /> 14 + <style> 15 + .logs-container { 16 + padding-bottom: 88px; 17 + display: flex; 18 + flex-direction: column; 19 + height: calc(100vh - 56px - 88px); 20 + } 21 + 22 + .logs-search { 23 + padding: 12px 16px; 24 + border-bottom: 1px solid var(--border); 25 + display: flex; 26 + gap: 8px; 27 + align-items: center; 28 + } 29 + 30 + .logs-search-input { 31 + flex: 1; 32 + padding: 8px 12px; 33 + border: 1px solid var(--border); 34 + border-radius: 8px; 35 + background-color: var(--surface); 36 + color: var(--text-primary); 37 + font-size: 13px; 38 + font-family: var(--font-mono); 39 + } 40 + 41 + .logs-search-input:focus { 42 + outline: none; 43 + border-color: var(--accent-primary); 44 + } 45 + 46 + .logs-search-input::placeholder { 47 + color: var(--text-muted); 48 + } 49 + 50 + .logs-filters { 51 + padding: 8px 16px; 52 + border-bottom: 1px solid var(--border); 53 + display: flex; 54 + gap: 6px; 55 + overflow-x: auto; 56 + -webkit-overflow-scrolling: touch; 57 + } 58 + 59 + .logs-filters::-webkit-scrollbar { 60 + display: none; 61 + } 62 + 63 + .filter-chip { 64 + display: inline-flex; 65 + align-items: center; 66 + gap: 4px; 67 + padding: 4px 10px; 68 + border-radius: 9999px; 69 + border: 1px solid var(--border); 70 + background-color: var(--surface); 71 + color: var(--text-secondary); 72 + font-size: 12px; 73 + font-weight: 600; 74 + cursor: pointer; 75 + transition: all 0.2s ease; 76 + white-space: nowrap; 77 + flex-shrink: 0; 78 + } 79 + 80 + .filter-chip:hover { 81 + background-color: var(--surface-variant); 82 + } 83 + 84 + .filter-chip.active { 85 + border-color: var(--accent-primary); 86 + background-color: var(--accent-primary); 87 + color: white; 88 + } 89 + 90 + .filter-chip-dot { 91 + width: 6px; 92 + height: 6px; 93 + border-radius: 50%; 94 + flex-shrink: 0; 95 + } 96 + 97 + .filter-chip.active .filter-chip-dot { 98 + background-color: rgba(255, 255, 255, 0.6); 99 + } 100 + 101 + .dot-fatal { 102 + background-color: var(--accent-error); 103 + } 104 + .dot-error { 105 + background-color: var(--accent-error); 106 + } 107 + .dot-warning { 108 + background-color: var(--accent-warning); 109 + } 110 + .dot-info { 111 + background-color: var(--accent-primary); 112 + } 113 + .dot-debug { 114 + background-color: var(--text-muted); 115 + } 116 + .dot-trace { 117 + background-color: var(--text-muted); 118 + } 119 + 120 + .logs-list { 121 + flex: 1; 122 + overflow-y: auto; 123 + overflow-x: hidden; 124 + } 125 + 126 + .log-entry { 127 + display: flex; 128 + gap: 8px; 129 + padding: 8px 16px; 130 + border-bottom: 1px solid var(--border); 131 + align-items: flex-start; 132 + transition: background-color 0.15s ease; 133 + cursor: pointer; 134 + } 135 + 136 + .log-entry:hover { 137 + background-color: var(--surface); 138 + } 139 + 140 + .log-entry.expanded { 141 + background-color: var(--surface); 142 + } 143 + 144 + .log-timestamp { 145 + font-family: "JetBrains Mono", monospace; 146 + font-size: 11px; 147 + color: var(--text-muted); 148 + white-space: nowrap; 149 + padding-top: 1px; 150 + flex-shrink: 0; 151 + } 152 + 153 + .log-badge { 154 + font-family: "JetBrains Mono", monospace; 155 + font-size: 10px; 156 + font-weight: 700; 157 + padding: 1px 5px; 158 + border-radius: 3px; 159 + flex-shrink: 0; 160 + text-align: center; 161 + min-width: 18px; 162 + margin-top: 1px; 163 + } 164 + 165 + .log-badge-fatal { 166 + background-color: var(--accent-error); 167 + color: white; 168 + } 169 + 170 + .log-badge-error { 171 + background-color: rgba(239, 68, 68, 0.15); 172 + color: var(--accent-error); 173 + } 174 + 175 + .log-badge-warning { 176 + background-color: rgba(245, 158, 11, 0.15); 177 + color: var(--accent-warning); 178 + } 179 + 180 + .log-badge-info { 181 + background-color: rgba(0, 102, 255, 0.1); 182 + color: var(--accent-primary); 183 + } 184 + 185 + .log-badge-debug { 186 + background-color: var(--surface-variant); 187 + color: var(--text-secondary); 188 + } 189 + 190 + .log-badge-trace { 191 + background-color: var(--surface); 192 + color: var(--text-muted); 193 + } 194 + 195 + .log-message { 196 + font-family: "JetBrains Mono", monospace; 197 + font-size: 12px; 198 + line-height: 1.5; 199 + color: var(--text-primary); 200 + flex: 1; 201 + min-width: 0; 202 + overflow: hidden; 203 + text-overflow: ellipsis; 204 + display: -webkit-box; 205 + line-clamp: 2; 206 + -webkit-line-clamp: 2; 207 + -webkit-box-orient: vertical; 208 + } 209 + 210 + .log-entry.expanded .log-message { 211 + line-clamp: unset; 212 + -webkit-line-clamp: unset; 213 + overflow: visible; 214 + word-break: break-all; 215 + } 216 + 217 + .log-source { 218 + font-family: "JetBrains Mono", monospace; 219 + font-size: 11px; 220 + color: var(--text-muted); 221 + margin-top: 2px; 222 + } 223 + 224 + .autoscroll-indicator { 225 + position: sticky; 226 + bottom: 0; 227 + display: flex; 228 + align-items: center; 229 + justify-content: center; 230 + padding: 6px; 231 + background-color: var(--surface); 232 + border-top: 1px solid var(--border); 233 + font-size: 12px; 234 + color: var(--text-muted); 235 + gap: 4px; 236 + cursor: pointer; 237 + } 238 + 239 + .autoscroll-indicator svg { 240 + width: 14px; 241 + height: 14px; 242 + } 243 + 244 + .autoscroll-indicator.active { 245 + color: var(--accent-primary); 246 + } 247 + 248 + .logs-empty { 249 + flex: 1; 250 + display: flex; 251 + flex-direction: column; 252 + align-items: center; 253 + justify-content: center; 254 + padding: 48px 24px; 255 + text-align: center; 256 + } 257 + 258 + .logs-empty svg { 259 + width: 48px; 260 + height: 48px; 261 + color: var(--text-muted); 262 + margin-bottom: 16px; 263 + } 264 + 265 + .logs-empty-title { 266 + font-size: 16px; 267 + font-weight: 600; 268 + color: var(--text-primary); 269 + margin-bottom: 4px; 270 + } 271 + 272 + .logs-empty-text { 273 + font-size: 13px; 274 + color: var(--text-secondary); 275 + } 276 + </style> 277 + </head> 278 + <body> 279 + <div class="mobile-container"> 280 + <!-- Header --> 281 + <header class="header"> 282 + <button class="header-action"> 283 + <svg 284 + width="20" 285 + height="20" 286 + viewBox="0 0 24 24" 287 + fill="none" 288 + stroke="currentColor" 289 + stroke-width="2" 290 + stroke-linecap="round" 291 + stroke-linejoin="round"> 292 + <polyline points="15 18 9 12 15 6" /> 293 + </svg> 294 + </button> 295 + <h1 class="header-title">Logs</h1> 296 + <div style="display: flex; gap: 4px"> 297 + <button class="header-action" title="Share log file"> 298 + <svg 299 + width="18" 300 + height="18" 301 + viewBox="0 0 24 24" 302 + fill="none" 303 + stroke="currentColor" 304 + stroke-width="2" 305 + stroke-linecap="round" 306 + stroke-linejoin="round"> 307 + <path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /> 308 + <polyline points="16 6 12 2 8 6" /> 309 + <line x1="12" y1="2" x2="12" y2="15" /> 310 + </svg> 311 + </button> 312 + <button class="header-action" title="Clear all logs" style="color: var(--accent-error)"> 313 + <svg 314 + width="18" 315 + height="18" 316 + viewBox="0 0 24 24" 317 + fill="none" 318 + stroke="currentColor" 319 + stroke-width="2" 320 + stroke-linecap="round" 321 + stroke-linejoin="round"> 322 + <polyline points="3 6 5 6 21 6" /> 323 + <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> 324 + </svg> 325 + </button> 326 + </div> 327 + </header> 328 + 329 + <div class="logs-container"> 330 + <!-- Search --> 331 + <div class="logs-search"> 332 + <svg 333 + width="16" 334 + height="16" 335 + viewBox="0 0 24 24" 336 + fill="none" 337 + stroke="var(--text-muted)" 338 + stroke-width="2" 339 + stroke-linecap="round" 340 + stroke-linejoin="round"> 341 + <circle cx="11" cy="11" r="8" /> 342 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 343 + </svg> 344 + <input class="logs-search-input" type="text" placeholder="Filter logs..." /> 345 + </div> 346 + 347 + <!-- Level Filter Chips --> 348 + <div class="logs-filters"> 349 + <button class="filter-chip active"> 350 + <span class="filter-chip-dot dot-fatal"></span> 351 + Fatal 352 + </button> 353 + <button class="filter-chip active"> 354 + <span class="filter-chip-dot dot-error"></span> 355 + Error 356 + </button> 357 + <button class="filter-chip active"> 358 + <span class="filter-chip-dot dot-warning"></span> 359 + Warning 360 + </button> 361 + <button class="filter-chip active"> 362 + <span class="filter-chip-dot dot-info"></span> 363 + Info 364 + </button> 365 + <button class="filter-chip"> 366 + <span class="filter-chip-dot dot-debug"></span> 367 + Debug 368 + </button> 369 + <button class="filter-chip"> 370 + <span class="filter-chip-dot dot-trace"></span> 371 + Trace 372 + </button> 373 + </div> 374 + 375 + <!-- Log Entries --> 376 + <div class="logs-list"> 377 + <div class="log-entry"> 378 + <span class="log-timestamp">14:32:01.123</span> 379 + <span class="log-badge log-badge-info">I</span> 380 + <div> 381 + <div class="log-message">App started — session abc123</div> 382 + <div class="log-source">AppLogger</div> 383 + </div> 384 + </div> 385 + 386 + <div class="log-entry"> 387 + <span class="log-timestamp">14:32:01.456</span> 388 + <span class="log-badge log-badge-info">I</span> 389 + <div> 390 + <div class="log-message">OAuth session restored for did:plc:z72i7hdy...</div> 391 + <div class="log-source">AuthBloc</div> 392 + </div> 393 + </div> 394 + 395 + <div class="log-entry"> 396 + <span class="log-timestamp">14:32:02.012</span> 397 + <span class="log-badge log-badge-info">I</span> 398 + <div> 399 + <div class="log-message">Route pushed: /home</div> 400 + <div class="log-source">NavObserver</div> 401 + </div> 402 + </div> 403 + 404 + <div class="log-entry"> 405 + <span class="log-timestamp">14:32:02.340</span> 406 + <span class="log-badge log-badge-warning">W</span> 407 + <div> 408 + <div class="log-message">Timeline fetch retry (attempt 2/3) — SocketException: Connection reset</div> 409 + <div class="log-source">FeedBloc</div> 410 + </div> 411 + </div> 412 + 413 + <div class="log-entry"> 414 + <span class="log-timestamp">14:32:03.891</span> 415 + <span class="log-badge log-badge-info">I</span> 416 + <div> 417 + <div class="log-message">GET /xrpc/app.bsky.feed.getTimeline — 200 (342ms)</div> 418 + <div class="log-source">HttpLogger</div> 419 + </div> 420 + </div> 421 + 422 + <div class="log-entry expanded"> 423 + <span class="log-timestamp">14:32:05.220</span> 424 + <span class="log-badge log-badge-error">E</span> 425 + <div> 426 + <div class="log-message"> 427 + Failed to decode feed post: type 'Null' is not a subtype of type 'String' in type cast #0 428 + FeedRepository.decodeFeedViewPost (feed_repository.dart:142) #1 FeedBloc._onTimelineRequested 429 + (feed_bloc.dart:58) 430 + </div> 431 + <div class="log-source">FeedBloc</div> 432 + </div> 433 + </div> 434 + 435 + <div class="log-entry"> 436 + <span class="log-timestamp">14:32:06.100</span> 437 + <span class="log-badge log-badge-info">I</span> 438 + <div> 439 + <div class="log-message">Route pushed: /profile/did:plc:z72i7hdy...</div> 440 + <div class="log-source">NavObserver</div> 441 + </div> 442 + </div> 443 + 444 + <div class="log-entry"> 445 + <span class="log-timestamp">14:32:06.540</span> 446 + <span class="log-badge log-badge-info">I</span> 447 + <div> 448 + <div class="log-message">GET /xrpc/app.bsky.actor.getProfile — 200 (198ms)</div> 449 + <div class="log-source">HttpLogger</div> 450 + </div> 451 + </div> 452 + 453 + <div class="log-entry"> 454 + <span class="log-timestamp">14:32:07.010</span> 455 + <span class="log-badge log-badge-warning">W</span> 456 + <div> 457 + <div class="log-message">Image cache miss for avatar CDN — falling back to network fetch</div> 458 + <div class="log-source">ImageCache</div> 459 + </div> 460 + </div> 461 + 462 + <div class="log-entry"> 463 + <span class="log-timestamp">14:32:08.330</span> 464 + <span class="log-badge log-badge-info">I</span> 465 + <div> 466 + <div class="log-message">ProfileBloc transition: ProfileLoading → ProfileLoaded</div> 467 + <div class="log-source">BlocObserver</div> 468 + </div> 469 + </div> 470 + 471 + <div class="log-entry"> 472 + <span class="log-timestamp">14:32:12.450</span> 473 + <span class="log-badge log-badge-fatal">F</span> 474 + <div> 475 + <div class="log-message">Unhandled exception in zone — FormatException: Invalid JSON at position 0</div> 476 + <div class="log-source">AppLogger</div> 477 + </div> 478 + </div> 479 + </div> 480 + 481 + <!-- Auto-scroll indicator --> 482 + <div class="autoscroll-indicator active"> 483 + <svg 484 + viewBox="0 0 24 24" 485 + fill="none" 486 + stroke="currentColor" 487 + stroke-width="2" 488 + stroke-linecap="round" 489 + stroke-linejoin="round"> 490 + <polyline points="6 9 12 15 18 9" /> 491 + </svg> 492 + Auto-scroll 493 + </div> 494 + </div> 495 + 496 + <!-- Bottom Navigation --> 497 + <nav class="nav-bar"> 498 + <a href="home.html" class="nav-item"> 499 + <svg 500 + viewBox="0 0 24 24" 501 + fill="none" 502 + stroke="currentColor" 503 + stroke-width="2" 504 + stroke-linecap="round" 505 + stroke-linejoin="round"> 506 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /> 507 + <polyline points="9 22 9 12 15 12 15 22" /> 508 + </svg> 509 + <span>Home</span> 510 + </a> 511 + 512 + <a href="search.html" class="nav-item"> 513 + <svg 514 + viewBox="0 0 24 24" 515 + fill="none" 516 + stroke="currentColor" 517 + stroke-width="2" 518 + stroke-linecap="round" 519 + stroke-linejoin="round"> 520 + <circle cx="11" cy="11" r="8" /> 521 + <line x1="21" y1="21" x2="16.65" y2="16.65" /> 522 + </svg> 523 + <span>Search</span> 524 + </a> 525 + 526 + <a href="profile.html" class="nav-item"> 527 + <svg 528 + viewBox="0 0 24 24" 529 + fill="none" 530 + stroke="currentColor" 531 + stroke-width="2" 532 + stroke-linecap="round" 533 + stroke-linejoin="round"> 534 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> 535 + <circle cx="12" cy="7" r="4" /> 536 + </svg> 537 + <span>Profile</span> 538 + </a> 539 + 540 + <a href="settings.html" class="nav-item"> 541 + <svg 542 + viewBox="0 0 24 24" 543 + fill="none" 544 + stroke="currentColor" 545 + stroke-width="2" 546 + stroke-linecap="round" 547 + stroke-linejoin="round"> 548 + <circle cx="12" cy="12" r="3" /> 549 + <path 550 + d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /> 551 + </svg> 552 + <span>Settings</span> 553 + </a> 554 + </nav> 555 + </div> 556 + 557 + <script> 558 + // Theme init 559 + const saved = localStorage.getItem("theme"); 560 + if (saved && saved !== "light") { 561 + document.documentElement.setAttribute("data-theme", saved); 562 + } 563 + 564 + // Toggle filter chips 565 + document.querySelectorAll(".filter-chip").forEach((chip) => { 566 + chip.addEventListener("click", () => chip.classList.toggle("active")); 567 + }); 568 + 569 + // Toggle expanded log entries 570 + document.querySelectorAll(".log-entry").forEach((entry) => { 571 + entry.addEventListener("click", () => entry.classList.toggle("expanded")); 572 + }); 573 + 574 + // Toggle auto-scroll 575 + document.querySelector(".autoscroll-indicator").addEventListener("click", function () { 576 + this.classList.toggle("active"); 577 + }); 578 + </script> 579 + </body> 580 + </html>
+21
docs/designs/settings.html
··· 456 456 </svg> 457 457 </div> 458 458 </a> 459 + 460 + <a href="logs.html" class="settings-item" style="text-decoration:none;"> 461 + <div class="settings-item-left"> 462 + <svg class="settings-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 463 + <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/> 464 + <polyline points="14 2 14 8 20 8"/> 465 + <line x1="16" y1="13" x2="8" y2="13"/> 466 + <line x1="16" y1="17" x2="8" y2="17"/> 467 + <polyline points="10 9 9 9 8 9"/> 468 + </svg> 469 + <div class="settings-item-content"> 470 + <div class="settings-item-title">Logs</div> 471 + <div class="settings-item-subtitle">View app log files</div> 472 + </div> 473 + </div> 474 + <div class="settings-item-right"> 475 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 476 + <polyline points="9 18 15 12 9 6"/> 477 + </svg> 478 + </div> 479 + </a> 459 480 460 481 <div class="settings-item"> 461 482 <div class="settings-item-left">
+80
docs/specs/phase-2.md
··· 1 1 # Lazurite Phase 2 Spec 2 2 3 + ## Logging 4 + 5 + Structured logging for development debugging and in-app log inspection. Uses 6 + the [`logger`](https://pub.dev/packages/logger) package (v2.x) — the most 7 + widely adopted Flutter logging library — with file-based persistence via its 8 + built-in `AdvancedFileOutput`. 9 + 10 + ### Log Levels 11 + 12 + | Level | Usage | 13 + | --------- | ------------------------------------------- | 14 + | `trace` | Fine-grained control flow (loop iterations) | 15 + | `debug` | Development-only diagnostics | 16 + | `info` | Significant lifecycle events (login, nav) | 17 + | `warning` | Recoverable issues (retry, fallback) | 18 + | `error` | Failures with stack traces | 19 + | `fatal` | Unrecoverable errors (crash-level) | 20 + 21 + ### Architecture 22 + 23 + A single `AppLogger` wrapper class exposes a top-level `log` instance injected 24 + via the service locator. All subsystems log through this instance. 25 + 26 + ```sh 27 + AppLogger 28 + ├── LogFilter (DevelopmentFilter / ProductionFilter) 29 + ├── LogPrinter (PrettyPrinter for dev, SimplePrinter for file) 30 + └── LogOutput 31 + ├── ConsoleOutput (always, dev only) 32 + └── AdvancedFileOutput (always, all builds) 33 + ``` 34 + 35 + **Console output** — enabled only in debug builds via `DevelopmentFilter`. 36 + Uses `PrettyPrinter` with method counts, colors, and emojis for readability 37 + in the terminal. 38 + 39 + **File output** — enabled in all builds. Uses `AdvancedFileOutput` which 40 + writes to the app's documents directory (`getApplicationDocumentsDirectory()`). 41 + Files are rotated daily with a configurable retention window (default 3 days). 42 + File format: `lazurite_YYYY-MM-DD.log`, one line per event using 43 + `SimplePrinter(colors: false)`. 44 + 45 + ### Integration Points 46 + 47 + | Subsystem | What gets logged | 48 + | --------- | ----------------------------------------------- | 49 + | BLoC | State transitions via `BlocObserver` override | 50 + | HTTP | Request/response summaries (no auth headers) | 51 + | Auth | OAuth flow steps, token refresh, session events | 52 + | Nav | Route changes via `NavigatorObserver` | 53 + | DB | Drift query errors | 54 + 55 + **Security:** Never log access tokens, refresh tokens, passwords, or full 56 + request/response bodies. HTTP logging redacts the `Authorization` header and 57 + truncates bodies to 200 chars. 58 + 59 + ### In-App Log Viewer 60 + 61 + Accessible via Settings → Dev Tools → Logs. Reads log files from disk and 62 + displays entries in a scrollable, filterable list. 63 + 64 + **Features:** 65 + 66 + 1. **Level filter** — chip bar to toggle visibility per level 67 + 2. **Search** — free-text filter across log messages 68 + 3. **Auto-scroll** — locks to bottom for live tailing; unlocks on manual scroll 69 + 4. **Share** — export current day's log file via the system share sheet 70 + 5. **Clear** — delete all log files with confirmation 71 + 72 + Each log entry renders as a single row: 73 + 74 + | Element | Format | 75 + | --------- | ----------------------------------- | 76 + | Timestamp | `HH:mm:ss.SSS` in monospace | 77 + | Level | Colored badge (E / W / I / D) | 78 + | Message | Truncated to 2 lines, tap to expand | 79 + 80 + No Bloc needed — use a `LogViewerCubit` with simple file-read state, since 81 + this is a stateless inspection tool. 82 + 3 83 ## Feeds 4 84 5 85 Phase 1 builds profile author feeds only. Phase 2 adds the full home feed
+5 -5
docs/tasks/phase-1.md
··· 19 19 20 20 ## M2 — Profile Rendering 21 21 22 - - [ ] Build `ProfileBloc` — fetch via `getProfile` / `getProfiles` 23 - - [ ] Profile screen: avatar, banner, display name, handle, description, stats (followers/following/posts), pronouns, website 24 - - [ ] Build `FeedBloc` — paginated fetch via `getAuthorFeed` with cursor + filter support 25 - - [ ] Post card widget: text, timestamps, embeds (images, quote posts, link cards) 26 - - [ ] Facet rendering: parse via `bluesky_text`, render mentions / links / hashtags as tappable spans (UTF-8 byte-safe) 22 + - [x] Build `ProfileBloc` — fetch via `getProfile` / `getProfiles` 23 + - [x] Profile screen: avatar, banner, display name, handle, description, stats (followers/following/posts), pronouns, website 24 + - [x] Build `FeedBloc` — paginated fetch via `getAuthorFeed` with cursor + filter support 25 + - [x] Post card widget: text, timestamps, embeds (images, quote posts, link cards) 26 + - [x] Facet rendering: parse via `bluesky_text`, render mentions / links / hashtags as tappable spans (UTF-8 byte-safe) 27 27 28 28 ## M3 — Settings & Theming 29 29
+18 -3
docs/tasks/phase-2.md
··· 1 1 # Phase 2 Milestones 2 2 3 - ## M5 — Feeds 3 + ## M5 — Logging 4 + 5 + - [ ] Add `logger` package dependency 6 + - [ ] `AppLogger` wrapper — singleton with `DevelopmentFilter` + `PrettyPrinter` for console, `AdvancedFileOutput` + `SimplePrinter` for file 7 + - [ ] File rotation — daily log files in app documents dir (`lazurite_YYYY-MM-DD.log`), 3-day retention 8 + - [ ] `LoggingBlocObserver` — log BLoC state transitions at `debug` level 9 + - [ ] HTTP logging interceptor — request/response summaries, redact `Authorization` header, truncate bodies 10 + - [ ] `NavigatorObserver` subclass — log route changes at `info` level 11 + - [ ] Log viewer screen — scrollable list reading from log files on disk 12 + - [ ] Level filter chip bar — toggle visibility per log level 13 + - [ ] Free-text search across log messages 14 + - [ ] Share button — export current day's log file via system share sheet 15 + - [ ] Clear all logs with confirmation dialog 16 + - [ ] Add "Logs" entry under Dev Tools in Settings screen 17 + 18 + ## M6 — Feeds 4 19 5 20 - [ ] Build home screen with horizontally-swipable tab bar (one tab per pinned feed) 6 21 - [ ] Implement timeline feed via `getTimeline` with cursor pagination ··· 10 25 - [ ] Feed discovery screen via `getSuggestedFeeds` — browse and add generators 11 26 - [ ] Feed management UI — pin/unpin, drag-to-reorder, remove saved feeds 12 27 13 - ## M6 — Search 28 + ## M7 — Search 14 29 15 30 - [ ] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors) 16 31 - [ ] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted` ··· 20 35 - [ ] Drift migration: add `search_history` table (query, type, searched_at, account_did) 21 36 - [ ] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account 22 37 23 - ## M7 — Dev Tools (PDS Explorer) 38 + ## M8 — Dev Tools (PDS Explorer) 24 39 25 40 - [ ] `DevToolsCubit` with request/response state for stateless exploration 26 41 - [ ] Handle / DID input with resolution via `resolveHandle`
+4 -1
lib/core/router/app_router.dart
··· 31 31 routes: [ 32 32 GoRoute(path: '/', builder: (context, state) => const HomeScreen()), 33 33 GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), 34 - GoRoute(path: '/profile', builder: (context, state) => const ProfileScreen()), 34 + GoRoute( 35 + path: '/profile', 36 + builder: (context, state) => ProfileScreen(actor: state.uri.queryParameters['actor']), 37 + ), 35 38 GoRoute(path: '/settings', builder: (context, state) => const SettingsScreen()), 36 39 ], 37 40 );
+2 -2
lib/features/auth/data/auth_repository.dart
··· 287 287 final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); 288 288 resolvedHandle = authSession.data.handle; 289 289 } catch (_) { 290 - // Fall back to the login hint if the server session lookup fails. 290 + // TODO: log this -> Fall back to the login hint if the server session lookup fails. 291 291 } 292 292 293 293 try { 294 294 final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); 295 295 displayName = profile.data.displayName; 296 296 } catch (_) { 297 - // Display name is optional and should not block session persistence. 297 + // TODO: log this -> Display name is optional and should not block session persistence. 298 298 } 299 299 300 300 return AuthTokens(
+87
lib/features/feed/bloc/feed_bloc.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 5 + 6 + export 'package:lazurite/features/feed/data/feed_repository.dart' show FeedFilter; 7 + 8 + part 'feed_event.dart'; 9 + part 'feed_state.dart'; 10 + 11 + class FeedBloc extends Bloc<FeedEvent, FeedState> { 12 + FeedBloc({required FeedRepository feedRepository}) 13 + : _feedRepository = feedRepository, 14 + super(const FeedState.initial()) { 15 + on<FeedLoadRequested>(_onFeedLoadRequested); 16 + on<FeedLoadMoreRequested>(_onFeedLoadMoreRequested); 17 + on<FeedRefreshRequested>(_onFeedRefreshRequested); 18 + } 19 + 20 + final FeedRepository _feedRepository; 21 + 22 + Future<void> _onFeedLoadRequested(FeedLoadRequested event, Emitter<FeedState> emit) async { 23 + emit(FeedState.loading(actor: event.actor, filter: event.filter)); 24 + 25 + try { 26 + final result = await _feedRepository.getAuthorFeed(actor: event.actor, filter: event.filter, limit: event.limit); 27 + 28 + emit( 29 + FeedState.loaded( 30 + actor: event.actor, 31 + posts: result.posts, 32 + cursor: result.cursor, 33 + filter: event.filter, 34 + hasMore: result.cursor != null, 35 + ), 36 + ); 37 + } catch (error) { 38 + emit(FeedState.error('Failed to load feed: $error')); 39 + } 40 + } 41 + 42 + Future<void> _onFeedLoadMoreRequested(FeedLoadMoreRequested event, Emitter<FeedState> emit) async { 43 + if (state.status != FeedStatus.loaded || state.actor == null || state.cursor == null || state.isLoadingMore) { 44 + return; 45 + } 46 + 47 + emit(state.copyWith(isLoadingMore: true)); 48 + 49 + try { 50 + final result = await _feedRepository.getAuthorFeed( 51 + actor: state.actor!, 52 + filter: state.filter, 53 + cursor: state.cursor, 54 + limit: event.limit, 55 + ); 56 + 57 + emit( 58 + state.copyWith( 59 + posts: [...state.posts, ...result.posts], 60 + cursor: result.cursor, 61 + hasMore: result.cursor != null, 62 + isLoadingMore: false, 63 + ), 64 + ); 65 + } catch (error) { 66 + emit(state.copyWith(isLoadingMore: false, hasMore: false)); 67 + } 68 + } 69 + 70 + Future<void> _onFeedRefreshRequested(FeedRefreshRequested event, Emitter<FeedState> emit) async { 71 + if (state.status != FeedStatus.loaded || state.actor == null) { 72 + return; 73 + } 74 + 75 + emit(state.copyWith(isRefreshing: true)); 76 + 77 + try { 78 + final result = await _feedRepository.getAuthorFeed(actor: state.actor!, filter: state.filter, limit: 50); 79 + 80 + emit( 81 + state.copyWith(posts: result.posts, cursor: result.cursor, hasMore: result.cursor != null, isRefreshing: false), 82 + ); 83 + } catch (error) { 84 + emit(state.copyWith(isRefreshing: false)); 85 + } 86 + } 87 + }
+30
lib/features/feed/bloc/feed_event.dart
··· 1 + part of 'feed_bloc.dart'; 2 + 3 + abstract class FeedEvent extends Equatable { 4 + const FeedEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + class FeedLoadRequested extends FeedEvent { 11 + const FeedLoadRequested({required this.actor, this.filter = FeedFilter.postsAndAuthorThreads, this.limit = 50}); 12 + final String actor; 13 + final FeedFilter filter; 14 + final int limit; 15 + 16 + @override 17 + List<Object?> get props => [actor, filter, limit]; 18 + } 19 + 20 + class FeedLoadMoreRequested extends FeedEvent { 21 + const FeedLoadMoreRequested({this.limit = 50}); 22 + final int limit; 23 + 24 + @override 25 + List<Object?> get props => [limit]; 26 + } 27 + 28 + class FeedRefreshRequested extends FeedEvent { 29 + const FeedRefreshRequested(); 30 + }
+73
lib/features/feed/bloc/feed_state.dart
··· 1 + part of 'feed_bloc.dart'; 2 + 3 + enum FeedStatus { initial, loading, loaded, error } 4 + 5 + class FeedState extends Equatable { 6 + const FeedState._({ 7 + required this.status, 8 + this.actor, 9 + this.posts = const [], 10 + this.cursor, 11 + this.errorMessage, 12 + this.filter = FeedFilter.postsAndAuthorThreads, 13 + this.hasMore = true, 14 + this.isLoadingMore = false, 15 + this.isRefreshing = false, 16 + }); 17 + 18 + const FeedState.initial() : this._(status: FeedStatus.initial); 19 + 20 + const FeedState.loading({required String actor, FeedFilter filter = FeedFilter.postsAndAuthorThreads}) 21 + : this._(status: FeedStatus.loading, actor: actor, filter: filter, hasMore: false); 22 + 23 + const FeedState.loaded({ 24 + required String actor, 25 + required List<FeedViewPost> posts, 26 + String? cursor, 27 + FeedFilter filter = FeedFilter.postsAndAuthorThreads, 28 + bool hasMore = true, 29 + }) : this._(status: FeedStatus.loaded, actor: actor, posts: posts, cursor: cursor, filter: filter, hasMore: hasMore); 30 + 31 + const FeedState.error(String message) : this._(status: FeedStatus.error, errorMessage: message); 32 + 33 + final FeedStatus status; 34 + final String? actor; 35 + final List<FeedViewPost> posts; 36 + final String? cursor; 37 + final String? errorMessage; 38 + final FeedFilter filter; 39 + final bool hasMore; 40 + final bool isLoadingMore; 41 + final bool isRefreshing; 42 + 43 + bool get isLoading => status == FeedStatus.loading; 44 + bool get hasError => status == FeedStatus.error; 45 + bool get hasPosts => posts.isNotEmpty; 46 + 47 + FeedState copyWith({ 48 + FeedStatus? status, 49 + String? actor, 50 + List<FeedViewPost>? posts, 51 + String? cursor, 52 + String? errorMessage, 53 + FeedFilter? filter, 54 + bool? hasMore, 55 + bool? isLoadingMore, 56 + bool? isRefreshing, 57 + }) { 58 + return FeedState._( 59 + status: status ?? this.status, 60 + actor: actor ?? this.actor, 61 + posts: posts ?? this.posts, 62 + cursor: cursor ?? this.cursor, 63 + errorMessage: errorMessage ?? this.errorMessage, 64 + filter: filter ?? this.filter, 65 + hasMore: hasMore ?? this.hasMore, 66 + isLoadingMore: isLoadingMore ?? this.isLoadingMore, 67 + isRefreshing: isRefreshing ?? this.isRefreshing, 68 + ); 69 + } 70 + 71 + @override 72 + List<Object?> get props => [status, actor, posts, cursor, errorMessage, filter, hasMore, isLoadingMore, isRefreshing]; 73 + }
+40
lib/features/feed/data/feed_repository.dart
··· 1 + import 'package:bluesky/app_bsky_feed_defs.dart'; 2 + import 'package:bluesky/app_bsky_feed_getauthorfeed.dart'; 3 + 4 + class FeedRepository { 5 + FeedRepository({required dynamic bluesky}) : _bluesky = bluesky; 6 + 7 + final dynamic _bluesky; 8 + 9 + Future<FeedResult> getAuthorFeed({ 10 + required String actor, 11 + FeedFilter filter = FeedFilter.postsAndAuthorThreads, 12 + String? cursor, 13 + int limit = 50, 14 + }) async { 15 + final bskyFilter = _mapToBskyFilter(filter); 16 + 17 + final response = await _bluesky.feed.getAuthorFeed(actor: actor, cursor: cursor, limit: limit, filter: bskyFilter); 18 + 19 + return FeedResult(posts: response.data.feed, cursor: response.data.cursor); 20 + } 21 + 22 + FeedGetAuthorFeedFilter? _mapToBskyFilter(FeedFilter filter) { 23 + switch (filter) { 24 + case FeedFilter.postsNoReplies: 25 + return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_no_replies); 26 + case FeedFilter.postsWithMedia: 27 + return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_with_media); 28 + case FeedFilter.postsAndAuthorThreads: 29 + return const FeedGetAuthorFeedFilter.knownValue(data: KnownFeedGetAuthorFeedFilter.posts_and_author_threads); 30 + } 31 + } 32 + } 33 + 34 + class FeedResult { 35 + FeedResult({required this.posts, this.cursor}); 36 + final List<FeedViewPost> posts; 37 + final String? cursor; 38 + } 39 + 40 + enum FeedFilter { postsNoReplies, postsWithMedia, postsAndAuthorThreads }
+210
lib/features/feed/presentation/widgets/facet_text.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:bluesky/app_bsky_richtext_facet.dart'; 4 + import 'package:bluesky_text/bluesky_text.dart'; 5 + import 'package:flutter/gestures.dart'; 6 + import 'package:flutter/material.dart'; 7 + import 'package:go_router/go_router.dart'; 8 + import 'package:url_launcher/url_launcher.dart'; 9 + 10 + class FacetText extends StatelessWidget { 11 + const FacetText({super.key, required this.text, this.facets, this.style, this.maxLines, this.overflow}); 12 + 13 + final String text; 14 + final List<RichtextFacet>? facets; 15 + final TextStyle? style; 16 + final int? maxLines; 17 + final TextOverflow? overflow; 18 + 19 + @override 20 + Widget build(BuildContext context) { 21 + return RichText( 22 + text: TextSpan(style: style, children: _buildTextSpans(context)), 23 + maxLines: maxLines, 24 + overflow: overflow ?? TextOverflow.clip, 25 + ); 26 + } 27 + 28 + List<InlineSpan> _buildTextSpans(BuildContext context) { 29 + final bytes = utf8.encode(text); 30 + final segments = _segmentsFromFacets(bytes) ?? _segmentsFromEntities(bytes); 31 + 32 + if (segments.isEmpty) { 33 + return [TextSpan(text: text)]; 34 + } 35 + 36 + final spans = <InlineSpan>[]; 37 + var currentByteIndex = 0; 38 + 39 + for (final segment in segments) { 40 + final startByte = segment.start.clamp(0, bytes.length); 41 + final endByte = segment.end.clamp(startByte, bytes.length); 42 + 43 + if (startByte > currentByteIndex) { 44 + spans.add(TextSpan(text: _extractTextFromBytes(bytes, currentByteIndex, startByte))); 45 + } 46 + 47 + final segmentText = _extractTextFromBytes(bytes, startByte, endByte); 48 + spans.add(segment.toSpan(context, segmentText)); 49 + currentByteIndex = endByte; 50 + } 51 + 52 + if (currentByteIndex < bytes.length) { 53 + spans.add(TextSpan(text: _extractTextFromBytes(bytes, currentByteIndex, bytes.length))); 54 + } 55 + 56 + return spans; 57 + } 58 + 59 + List<_TextSegment>? _segmentsFromFacets(List<int> bytes) { 60 + if (facets == null || facets!.isEmpty) { 61 + return null; 62 + } 63 + 64 + final sortedFacets = List<RichtextFacet>.from(facets!) 65 + ..sort((a, b) => a.index.byteStart.compareTo(b.index.byteStart)); 66 + final segments = <_TextSegment>[]; 67 + 68 + for (final facet in sortedFacets) { 69 + final segment = _segmentFromFacet(facet); 70 + if (segment == null) { 71 + continue; 72 + } 73 + 74 + if (facet.index.byteStart >= bytes.length) { 75 + continue; 76 + } 77 + 78 + segments.add(segment); 79 + } 80 + 81 + return segments; 82 + } 83 + 84 + List<_TextSegment> _segmentsFromEntities(List<int> bytes) { 85 + final entities = BlueskyText(text, enableMarkdown: false).entities.toList() 86 + ..sort((a, b) => a.indices.start.compareTo(b.indices.start)); 87 + final segments = <_TextSegment>[]; 88 + 89 + for (final entity in entities) { 90 + if (entity.indices.start >= bytes.length) { 91 + continue; 92 + } 93 + 94 + if (entity.type == EntityType.handle) { 95 + segments.add(_MentionSegment(entity.indices.start, entity.indices.end, entity.value.replaceFirst('@', ''))); 96 + continue; 97 + } 98 + 99 + if (entity.type == EntityType.link) { 100 + segments.add(_LinkSegment(entity.indices.start, entity.indices.end, entity.value)); 101 + continue; 102 + } 103 + 104 + if (entity.type == EntityType.tag) { 105 + segments.add(_TagSegment(entity.indices.start, entity.indices.end, entity.value.replaceFirst('#', ''))); 106 + } 107 + } 108 + 109 + return segments; 110 + } 111 + 112 + _TextSegment? _segmentFromFacet(RichtextFacet facet) { 113 + for (final feature in facet.features) { 114 + if (feature.isRichtextFacetLink && feature.richtextFacetLink != null) { 115 + return _LinkSegment(facet.index.byteStart, facet.index.byteEnd, feature.richtextFacetLink!.uri); 116 + } 117 + 118 + if (feature.isRichtextFacetMention && feature.richtextFacetMention != null) { 119 + return _MentionSegment(facet.index.byteStart, facet.index.byteEnd, feature.richtextFacetMention!.did); 120 + } 121 + 122 + if (feature.isRichtextFacetTag && feature.richtextFacetTag != null) { 123 + return _TagSegment(facet.index.byteStart, facet.index.byteEnd, feature.richtextFacetTag!.tag); 124 + } 125 + } 126 + 127 + return null; 128 + } 129 + 130 + String _extractTextFromBytes(List<int> bytes, int start, int end) { 131 + return utf8.decode(bytes.sublist(start, end), allowMalformed: true); 132 + } 133 + } 134 + 135 + abstract class _TextSegment { 136 + const _TextSegment(this.start, this.end); 137 + 138 + final int start; 139 + final int end; 140 + 141 + TextSpan toSpan(BuildContext context, String text); 142 + 143 + TextStyle _linkStyle(BuildContext context) { 144 + return TextStyle( 145 + color: Theme.of(context).colorScheme.primary, 146 + decoration: TextDecoration.underline, 147 + fontWeight: FontWeight.w600, 148 + ); 149 + } 150 + } 151 + 152 + final class _LinkSegment extends _TextSegment { 153 + const _LinkSegment(super.start, super.end, this.uri); 154 + 155 + final String uri; 156 + 157 + @override 158 + TextSpan toSpan(BuildContext context, String text) { 159 + return TextSpan( 160 + text: text, 161 + style: _linkStyle(context), 162 + recognizer: TapGestureRecognizer()..onTap = () => _launchExternal(Uri.parse(uri)), 163 + ); 164 + } 165 + } 166 + 167 + final class _MentionSegment extends _TextSegment { 168 + const _MentionSegment(super.start, super.end, this.actor); 169 + 170 + final String actor; 171 + 172 + @override 173 + TextSpan toSpan(BuildContext context, String text) { 174 + return TextSpan( 175 + text: text, 176 + style: _linkStyle(context), 177 + recognizer: TapGestureRecognizer()..onTap = () => _openProfile(context, actor), 178 + ); 179 + } 180 + } 181 + 182 + final class _TagSegment extends _TextSegment { 183 + const _TagSegment(super.start, super.end, this.tag); 184 + 185 + final String tag; 186 + 187 + @override 188 + TextSpan toSpan(BuildContext context, String text) { 189 + return TextSpan( 190 + text: text, 191 + style: _linkStyle(context), 192 + recognizer: TapGestureRecognizer()..onTap = () => _launchExternal(_searchUri(tag)), 193 + ); 194 + } 195 + } 196 + 197 + void _openProfile(BuildContext context, String actor) { 198 + final router = GoRouter.maybeOf(context); 199 + if (router == null) { 200 + return; 201 + } 202 + 203 + router.push('/profile?actor=${Uri.encodeQueryComponent(actor)}'); 204 + } 205 + 206 + Uri _searchUri(String tag) => Uri.https('bsky.app', '/search', {'q': '#$tag'}); 207 + 208 + Future<void> _launchExternal(Uri url) async { 209 + await launchUrl(url, mode: LaunchMode.externalApplication); 210 + }
+503
lib/features/feed/presentation/widgets/post_card.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:bluesky/app_bsky_embed_external.dart'; 3 + import 'package:bluesky/app_bsky_embed_images.dart'; 4 + import 'package:bluesky/app_bsky_embed_record.dart'; 5 + import 'package:bluesky/app_bsky_embed_recordwithmedia.dart'; 6 + import 'package:bluesky/app_bsky_embed_video.dart'; 7 + import 'package:bluesky/app_bsky_feed_defs.dart'; 8 + import 'package:bluesky/app_bsky_feed_post.dart'; 9 + import 'package:flutter/material.dart'; 10 + import 'package:go_router/go_router.dart'; 11 + import 'package:intl/intl.dart'; 12 + import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; 13 + import 'package:url_launcher/url_launcher.dart'; 14 + 15 + class PostCard extends StatelessWidget { 16 + const PostCard({super.key, required this.feedViewPost}); 17 + 18 + final FeedViewPost feedViewPost; 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + final post = feedViewPost.post; 23 + final record = _tryParseRecord(post.record); 24 + final embed = _buildEmbed(context, post.embed); 25 + 26 + return Card( 27 + margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 1), 28 + elevation: 0, 29 + shape: const RoundedRectangleBorder(), 30 + child: Padding( 31 + padding: const EdgeInsets.all(16), 32 + child: Column( 33 + crossAxisAlignment: CrossAxisAlignment.start, 34 + children: [ 35 + _buildHeader(context, post.author, record?.createdAt ?? post.indexedAt), 36 + if (record?.reply != null) ...[const SizedBox(height: 8), _buildReplyLabel(context)], 37 + if (record != null && record.text.isNotEmpty) ...[ 38 + const SizedBox(height: 12), 39 + FacetText(text: record.text, facets: record.facets, style: Theme.of(context).textTheme.bodyLarge), 40 + ], 41 + if (embed != null) ...[const SizedBox(height: 12), embed], 42 + const SizedBox(height: 12), 43 + _buildActions(context), 44 + ], 45 + ), 46 + ), 47 + ); 48 + } 49 + 50 + Widget _buildHeader(BuildContext context, ProfileViewBasic author, DateTime createdAt) { 51 + return Row( 52 + crossAxisAlignment: CrossAxisAlignment.start, 53 + children: [ 54 + CircleAvatar( 55 + radius: 22, 56 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 57 + backgroundImage: author.avatar != null ? NetworkImage(author.avatar!) : null, 58 + child: author.avatar == null 59 + ? Text(_initials(author.displayName ?? author.handle), style: Theme.of(context).textTheme.labelLarge) 60 + : null, 61 + ), 62 + const SizedBox(width: 12), 63 + Expanded( 64 + child: Column( 65 + crossAxisAlignment: CrossAxisAlignment.start, 66 + children: [ 67 + Text( 68 + author.displayName ?? author.handle, 69 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 70 + maxLines: 1, 71 + overflow: TextOverflow.ellipsis, 72 + ), 73 + const SizedBox(height: 2), 74 + Text( 75 + '@${author.handle} · ${_formatTime(createdAt)}', 76 + style: Theme.of( 77 + context, 78 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 79 + ), 80 + ], 81 + ), 82 + ), 83 + ], 84 + ); 85 + } 86 + 87 + Widget _buildReplyLabel(BuildContext context) { 88 + return Row( 89 + children: [ 90 + Icon(Icons.reply, size: 14, color: Theme.of(context).colorScheme.onSurfaceVariant), 91 + const SizedBox(width: 6), 92 + Text( 93 + 'Reply in a thread', 94 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 95 + ), 96 + ], 97 + ); 98 + } 99 + 100 + Widget _buildActions(BuildContext context) { 101 + final post = feedViewPost.post; 102 + 103 + return Row( 104 + mainAxisAlignment: MainAxisAlignment.spaceAround, 105 + children: [ 106 + _buildActionButton(context, Icons.chat_bubble_outline, '${post.replyCount ?? 0}'), 107 + _buildActionButton(context, Icons.repeat, '${post.repostCount ?? 0}'), 108 + _buildActionButton(context, Icons.favorite_border, '${post.likeCount ?? 0}'), 109 + _buildActionButton(context, Icons.share_outlined, ''), 110 + ], 111 + ); 112 + } 113 + 114 + Widget _buildActionButton(BuildContext context, IconData icon, String count) { 115 + final iconColor = Theme.of(context).colorScheme.onSurfaceVariant; 116 + 117 + return InkWell( 118 + onTap: () {}, 119 + borderRadius: BorderRadius.circular(999), 120 + child: Padding( 121 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), 122 + child: Row( 123 + children: [ 124 + Icon(icon, size: 18, color: iconColor), 125 + if (count.isNotEmpty) ...[ 126 + const SizedBox(width: 4), 127 + Text(count, style: Theme.of(context).textTheme.bodySmall?.copyWith(color: iconColor)), 128 + ], 129 + ], 130 + ), 131 + ), 132 + ); 133 + } 134 + 135 + Widget? _buildEmbed(BuildContext context, UPostViewEmbed? embed) { 136 + if (embed == null) { 137 + return null; 138 + } 139 + 140 + if (embed.isEmbedImagesView) { 141 + return _buildImagesEmbed(context, embed.embedImagesView!.images); 142 + } 143 + 144 + if (embed.isEmbedExternalView) { 145 + return _buildExternalEmbed(context, embed.embedExternalView!.external); 146 + } 147 + 148 + if (embed.isEmbedRecordView) { 149 + return _buildQuotedRecord(context, embed.embedRecordView!); 150 + } 151 + 152 + if (embed.isEmbedVideoView) { 153 + return _buildVideoEmbed(context, embed.embedVideoView!); 154 + } 155 + 156 + if (embed.isEmbedRecordWithMediaView) { 157 + final recordWithMedia = embed.embedRecordWithMediaView!; 158 + return Column( 159 + crossAxisAlignment: CrossAxisAlignment.start, 160 + children: [ 161 + _buildRecordWithMediaMedia(context, recordWithMedia.media), 162 + const SizedBox(height: 8), 163 + _buildQuotedRecord(context, recordWithMedia.record), 164 + ], 165 + ); 166 + } 167 + 168 + return null; 169 + } 170 + 171 + Widget _buildRecordWithMediaMedia(BuildContext context, UEmbedRecordWithMediaViewMedia media) { 172 + if (media.isEmbedImagesView) { 173 + return _buildImagesEmbed(context, media.embedImagesView!.images); 174 + } 175 + 176 + if (media.isEmbedExternalView) { 177 + return _buildExternalEmbed(context, media.embedExternalView!.external); 178 + } 179 + 180 + if (media.isEmbedVideoView) { 181 + return _buildVideoEmbed(context, media.embedVideoView!); 182 + } 183 + 184 + return const SizedBox.shrink(); 185 + } 186 + 187 + Widget _buildImagesEmbed(BuildContext context, List<EmbedImagesViewImage> images) { 188 + final crossAxisCount = images.length == 1 ? 1 : 2; 189 + final childAspectRatio = images.length == 1 ? 16 / 9 : 1.0; 190 + 191 + return ClipRRect( 192 + borderRadius: BorderRadius.circular(16), 193 + child: GridView.builder( 194 + shrinkWrap: true, 195 + physics: const NeverScrollableScrollPhysics(), 196 + itemCount: images.length, 197 + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( 198 + crossAxisCount: crossAxisCount, 199 + crossAxisSpacing: 2, 200 + mainAxisSpacing: 2, 201 + childAspectRatio: childAspectRatio, 202 + ), 203 + itemBuilder: (context, index) { 204 + final image = images[index]; 205 + return InkWell( 206 + onTap: () => _launchExternal(Uri.parse(image.fullsize)), 207 + child: Image.network( 208 + image.thumb, 209 + fit: BoxFit.cover, 210 + errorBuilder: (_, _, _) => ColoredBox( 211 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 212 + child: const Center(child: Icon(Icons.image_not_supported_outlined)), 213 + ), 214 + ), 215 + ); 216 + }, 217 + ), 218 + ); 219 + } 220 + 221 + Widget _buildExternalEmbed(BuildContext context, EmbedExternalViewExternal external) { 222 + return InkWell( 223 + onTap: () => _launchExternal(Uri.parse(external.uri)), 224 + borderRadius: BorderRadius.circular(16), 225 + child: Container( 226 + decoration: BoxDecoration( 227 + border: Border.all(color: Theme.of(context).dividerColor), 228 + borderRadius: BorderRadius.circular(16), 229 + ), 230 + child: Column( 231 + crossAxisAlignment: CrossAxisAlignment.start, 232 + children: [ 233 + if (external.thumb != null) 234 + ClipRRect( 235 + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), 236 + child: Image.network( 237 + external.thumb!, 238 + height: 160, 239 + width: double.infinity, 240 + fit: BoxFit.cover, 241 + errorBuilder: (_, _, _) => const SizedBox(height: 0), 242 + ), 243 + ), 244 + Padding( 245 + padding: const EdgeInsets.all(12), 246 + child: Column( 247 + crossAxisAlignment: CrossAxisAlignment.start, 248 + children: [ 249 + Text( 250 + external.title, 251 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 252 + ), 253 + if (external.description.isNotEmpty) ...[ 254 + const SizedBox(height: 4), 255 + Text( 256 + external.description, 257 + maxLines: 3, 258 + overflow: TextOverflow.ellipsis, 259 + style: Theme.of(context).textTheme.bodyMedium, 260 + ), 261 + ], 262 + const SizedBox(height: 8), 263 + Text( 264 + Uri.parse(external.uri).host, 265 + style: Theme.of( 266 + context, 267 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 268 + ), 269 + ], 270 + ), 271 + ), 272 + ], 273 + ), 274 + ), 275 + ); 276 + } 277 + 278 + Widget _buildVideoEmbed(BuildContext context, EmbedVideoView video) { 279 + return InkWell( 280 + onTap: () => _launchExternal(Uri.parse(video.playlist)), 281 + borderRadius: BorderRadius.circular(16), 282 + child: ClipRRect( 283 + borderRadius: BorderRadius.circular(16), 284 + child: Stack( 285 + alignment: Alignment.center, 286 + children: [ 287 + AspectRatio( 288 + aspectRatio: video.aspectRatio == null ? 16 / 9 : video.aspectRatio!.width / video.aspectRatio!.height, 289 + child: video.thumbnail == null 290 + ? ColoredBox( 291 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 292 + child: const SizedBox.expand(), 293 + ) 294 + : Image.network( 295 + video.thumbnail!, 296 + fit: BoxFit.cover, 297 + errorBuilder: (_, _, _) => ColoredBox( 298 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 299 + child: const SizedBox.expand(), 300 + ), 301 + ), 302 + ), 303 + Container( 304 + width: 56, 305 + height: 56, 306 + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.65), shape: BoxShape.circle), 307 + child: const Icon(Icons.play_arrow, color: Colors.white, size: 28), 308 + ), 309 + if (video.alt?.isNotEmpty ?? false) 310 + Positioned( 311 + left: 12, 312 + right: 12, 313 + bottom: 12, 314 + child: Text( 315 + video.alt!, 316 + maxLines: 2, 317 + overflow: TextOverflow.ellipsis, 318 + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white), 319 + ), 320 + ), 321 + ], 322 + ), 323 + ), 324 + ); 325 + } 326 + 327 + Widget _buildQuotedRecord(BuildContext context, EmbedRecordView recordView) { 328 + final record = recordView.record; 329 + 330 + if (record.isEmbedRecordViewRecord) { 331 + final quoted = record.embedRecordViewRecord!; 332 + final quotedRecord = _tryParseRecord(quoted.value); 333 + final nestedEmbed = _buildQuotedEmbeds(context, quoted.embeds); 334 + 335 + return Container( 336 + decoration: BoxDecoration( 337 + border: Border.all(color: Theme.of(context).dividerColor), 338 + borderRadius: BorderRadius.circular(16), 339 + ), 340 + child: InkWell( 341 + onTap: () { 342 + final router = GoRouter.maybeOf(context); 343 + if (router != null) { 344 + router.push('/profile?actor=${Uri.encodeQueryComponent(quoted.author.did)}'); 345 + } 346 + }, 347 + borderRadius: BorderRadius.circular(16), 348 + child: Padding( 349 + padding: const EdgeInsets.all(12), 350 + child: Column( 351 + crossAxisAlignment: CrossAxisAlignment.start, 352 + children: [ 353 + Row( 354 + children: [ 355 + CircleAvatar( 356 + radius: 14, 357 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 358 + backgroundImage: quoted.author.avatar != null ? NetworkImage(quoted.author.avatar!) : null, 359 + child: quoted.author.avatar == null 360 + ? Text(_initials(quoted.author.displayName ?? quoted.author.handle)) 361 + : null, 362 + ), 363 + const SizedBox(width: 8), 364 + Expanded( 365 + child: Text( 366 + '${quoted.author.displayName ?? quoted.author.handle} @${quoted.author.handle}', 367 + maxLines: 1, 368 + overflow: TextOverflow.ellipsis, 369 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), 370 + ), 371 + ), 372 + ], 373 + ), 374 + if (quotedRecord != null && quotedRecord.text.isNotEmpty) ...[ 375 + const SizedBox(height: 8), 376 + FacetText( 377 + text: quotedRecord.text, 378 + facets: quotedRecord.facets, 379 + style: Theme.of(context).textTheme.bodyMedium, 380 + maxLines: 6, 381 + overflow: TextOverflow.ellipsis, 382 + ), 383 + ], 384 + if (nestedEmbed != null) ...[const SizedBox(height: 8), nestedEmbed], 385 + ], 386 + ), 387 + ), 388 + ), 389 + ); 390 + } 391 + 392 + if (record.isEmbedRecordViewNotFound) { 393 + return _buildUnavailableQuote(context, 'Quoted post not found'); 394 + } 395 + 396 + if (record.isEmbedRecordViewBlocked) { 397 + return _buildUnavailableQuote(context, 'Quoted post is blocked'); 398 + } 399 + 400 + if (record.isEmbedRecordViewDetached) { 401 + return _buildUnavailableQuote(context, 'Quoted post is unavailable'); 402 + } 403 + 404 + return const SizedBox.shrink(); 405 + } 406 + 407 + Widget _buildUnavailableQuote(BuildContext context, String label) { 408 + return Container( 409 + width: double.infinity, 410 + padding: const EdgeInsets.all(12), 411 + decoration: BoxDecoration( 412 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 413 + borderRadius: BorderRadius.circular(16), 414 + ), 415 + child: Text( 416 + label, 417 + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 418 + ), 419 + ); 420 + } 421 + 422 + Widget? _buildQuotedEmbeds(BuildContext context, List<UEmbedRecordViewRecordEmbeds>? embeds) { 423 + if (embeds == null || embeds.isEmpty) { 424 + return null; 425 + } 426 + 427 + final embed = embeds.first; 428 + 429 + if (embed.isEmbedImagesView) { 430 + return _buildImagesEmbed(context, embed.embedImagesView!.images); 431 + } 432 + 433 + if (embed.isEmbedExternalView) { 434 + return _buildExternalEmbed(context, embed.embedExternalView!.external); 435 + } 436 + 437 + if (embed.isEmbedVideoView) { 438 + return _buildVideoEmbed(context, embed.embedVideoView!); 439 + } 440 + 441 + if (embed.isEmbedRecordWithMediaView) { 442 + final recordWithMedia = embed.embedRecordWithMediaView!; 443 + return Column( 444 + crossAxisAlignment: CrossAxisAlignment.start, 445 + children: [ 446 + _buildRecordWithMediaMedia(context, recordWithMedia.media), 447 + const SizedBox(height: 8), 448 + _buildQuotedRecord(context, recordWithMedia.record), 449 + ], 450 + ); 451 + } 452 + 453 + return null; 454 + } 455 + 456 + FeedPostRecord? _tryParseRecord(Map<String, dynamic> record) { 457 + try { 458 + return FeedPostRecord.fromJson(record); 459 + } catch (_) { 460 + return null; 461 + } 462 + } 463 + 464 + String _formatTime(DateTime time) { 465 + final now = DateTime.now(); 466 + final difference = now.difference(time); 467 + 468 + if (difference.inMinutes < 1) { 469 + return 'now'; 470 + } 471 + 472 + if (difference.inHours < 1) { 473 + return '${difference.inMinutes}m'; 474 + } 475 + 476 + if (difference.inDays < 1) { 477 + return '${difference.inHours}h'; 478 + } 479 + 480 + if (difference.inDays < 7) { 481 + return '${difference.inDays}d'; 482 + } 483 + 484 + return DateFormat('MMM d').format(time); 485 + } 486 + 487 + String _initials(String value) { 488 + final parts = value.trim().split(RegExp(r'\s+')); 489 + if (parts.isEmpty || parts.first.isEmpty) { 490 + return '?'; 491 + } 492 + 493 + if (parts.length == 1) { 494 + return parts.first.substring(0, 1).toUpperCase(); 495 + } 496 + 497 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 498 + } 499 + } 500 + 501 + Future<void> _launchExternal(Uri url) async { 502 + await launchUrl(url, mode: LaunchMode.externalApplication); 503 + }
+45
lib/features/profile/bloc/profile_bloc.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 2 + import 'package:equatable/equatable.dart'; 3 + import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 5 + 6 + part 'profile_event.dart'; 7 + part 'profile_state.dart'; 8 + 9 + class ProfileBloc extends Bloc<ProfileEvent, ProfileState> { 10 + ProfileBloc({required ProfileRepository profileRepository}) 11 + : _profileRepository = profileRepository, 12 + super(const ProfileState.initial()) { 13 + on<ProfileLoadRequested>(_onProfileLoadRequested); 14 + on<ProfileRefreshRequested>(_onProfileRefreshRequested); 15 + } 16 + 17 + final ProfileRepository _profileRepository; 18 + 19 + Future<void> _onProfileLoadRequested(ProfileLoadRequested event, Emitter<ProfileState> emit) async { 20 + emit(const ProfileState.loading()); 21 + 22 + try { 23 + final profile = await _profileRepository.getProfile(event.actor); 24 + emit(ProfileState.loaded(profile: profile)); 25 + } catch (error) { 26 + emit(ProfileState.error('Failed to load profile: $error')); 27 + } 28 + } 29 + 30 + Future<void> _onProfileRefreshRequested(ProfileRefreshRequested event, Emitter<ProfileState> emit) async { 31 + if (state.status != ProfileStatus.loaded || state.profile == null) { 32 + return; 33 + } 34 + 35 + final currentProfile = state.profile!; 36 + emit(state.copyWith(isRefreshing: true)); 37 + 38 + try { 39 + final profile = await _profileRepository.getProfile(currentProfile.did); 40 + emit(ProfileState.loaded(profile: profile)); 41 + } catch (error) { 42 + emit(state.copyWith(isRefreshing: false)); 43 + } 44 + } 45 + }
+20
lib/features/profile/bloc/profile_event.dart
··· 1 + part of 'profile_bloc.dart'; 2 + 3 + abstract class ProfileEvent extends Equatable { 4 + const ProfileEvent(); 5 + 6 + @override 7 + List<Object?> get props => []; 8 + } 9 + 10 + class ProfileLoadRequested extends ProfileEvent { 11 + const ProfileLoadRequested({required this.actor}); 12 + final String actor; 13 + 14 + @override 15 + List<Object?> get props => [actor]; 16 + } 17 + 18 + class ProfileRefreshRequested extends ProfileEvent { 19 + const ProfileRefreshRequested(); 20 + }
+42
lib/features/profile/bloc/profile_state.dart
··· 1 + part of 'profile_bloc.dart'; 2 + 3 + enum ProfileStatus { initial, loading, loaded, error } 4 + 5 + class ProfileState extends Equatable { 6 + const ProfileState._({required this.status, this.profile, this.errorMessage, this.isRefreshing = false}); 7 + 8 + const ProfileState.initial() : this._(status: ProfileStatus.initial); 9 + 10 + const ProfileState.loading() : this._(status: ProfileStatus.loading); 11 + 12 + const ProfileState.loaded({required ProfileViewDetailed profile}) 13 + : this._(status: ProfileStatus.loaded, profile: profile); 14 + 15 + const ProfileState.error(String message) : this._(status: ProfileStatus.error, errorMessage: message); 16 + 17 + final ProfileStatus status; 18 + final ProfileViewDetailed? profile; 19 + final String? errorMessage; 20 + final bool isRefreshing; 21 + 22 + bool get isLoading => status == ProfileStatus.loading; 23 + bool get hasError => status == ProfileStatus.error; 24 + bool get hasProfile => profile != null; 25 + 26 + ProfileState copyWith({ 27 + ProfileStatus? status, 28 + ProfileViewDetailed? profile, 29 + String? errorMessage, 30 + bool? isRefreshing, 31 + }) { 32 + return ProfileState._( 33 + status: status ?? this.status, 34 + profile: profile ?? this.profile, 35 + errorMessage: errorMessage ?? this.errorMessage, 36 + isRefreshing: isRefreshing ?? this.isRefreshing, 37 + ); 38 + } 39 + 40 + @override 41 + List<Object?> get props => [status, profile, errorMessage, isRefreshing]; 42 + }
+63
lib/features/profile/data/profile_repository.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:lazurite/core/database/app_database.dart'; 5 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 6 + 7 + class ProfileRepository { 8 + ProfileRepository({required AppDatabase database, required dynamic bluesky}) 9 + : _database = database, 10 + _bluesky = bluesky; 11 + 12 + final AppDatabase _database; 13 + final dynamic _bluesky; 14 + 15 + Future<ProfileViewDetailed> getProfile(String actor) async { 16 + try { 17 + final response = await _bluesky.actor.getProfile(actor: actor); 18 + final profile = response.data; 19 + 20 + await _database.cacheProfile(did: profile.did, handle: profile.handle, payload: jsonEncode(profile.toJson())); 21 + 22 + return profile; 23 + } catch (error) { 24 + final cachedProfile = await _getCachedProfile(actor); 25 + if (cachedProfile != null) { 26 + return cachedProfile; 27 + } 28 + 29 + rethrow; 30 + } 31 + } 32 + 33 + Future<List<ProfileView>> getProfiles(List<String> actors) async { 34 + final response = await _bluesky.actor.getProfiles(actors: actors); 35 + return response.data.profiles; 36 + } 37 + 38 + Future<ProfileViewDetailed?> getCurrentUserProfile(AuthTokens tokens) async { 39 + try { 40 + final response = await _bluesky.actor.getProfile(actor: tokens.did); 41 + return response.data; 42 + } catch (error) { 43 + return null; 44 + } 45 + } 46 + 47 + Future<ProfileViewDetailed?> _getCachedProfile(String actor) async { 48 + final cachedProfileByDid = await (_database.select( 49 + _database.cachedProfiles, 50 + )..where((profile) => profile.did.equals(actor))).getSingleOrNull(); 51 + final cachedProfile = 52 + cachedProfileByDid ?? 53 + await (_database.select( 54 + _database.cachedProfiles, 55 + )..where((profile) => profile.handle.equals(actor))).getSingleOrNull(); 56 + 57 + if (cachedProfile == null) { 58 + return null; 59 + } 60 + 61 + return ProfileViewDetailed.fromJson(jsonDecode(cachedProfile.payload) as Map<String, dynamic>); 62 + } 63 + }
+417 -25
lib/features/profile/presentation/profile_screen.dart
··· 1 + import 'package:bluesky/app_bsky_actor_defs.dart'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 + import 'package:go_router/go_router.dart'; 5 + import 'package:intl/intl.dart'; 3 6 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 + import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 8 + import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 9 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 10 + import 'package:url_launcher/url_launcher.dart'; 4 11 5 - class ProfileScreen extends StatelessWidget { 6 - const ProfileScreen({super.key}); 12 + class ProfileScreen extends StatefulWidget { 13 + const ProfileScreen({super.key, this.actor}); 14 + 15 + final String? actor; 16 + 17 + @override 18 + State<ProfileScreen> createState() => _ProfileScreenState(); 19 + } 20 + 21 + class _ProfileScreenState extends State<ProfileScreen> with SingleTickerProviderStateMixin { 22 + static const _tabs = [ 23 + (label: 'Posts', filter: FeedFilter.postsNoReplies), 24 + (label: 'Replies', filter: FeedFilter.postsAndAuthorThreads), 25 + (label: 'Media', filter: FeedFilter.postsWithMedia), 26 + ]; 27 + 28 + late final TabController _tabController; 29 + 30 + @override 31 + void initState() { 32 + super.initState(); 33 + _tabController = TabController(length: _tabs.length, vsync: this); 34 + _loadProfileAndFeed(); 35 + } 36 + 37 + @override 38 + void didUpdateWidget(covariant ProfileScreen oldWidget) { 39 + super.didUpdateWidget(oldWidget); 40 + if (oldWidget.actor != widget.actor) { 41 + _tabController.index = 0; 42 + _loadProfileAndFeed(); 43 + } 44 + } 45 + 46 + @override 47 + void dispose() { 48 + _tabController.dispose(); 49 + super.dispose(); 50 + } 51 + 52 + void _loadProfileAndFeed({FeedFilter? filter}) { 53 + final actor = _resolvedActor; 54 + if (actor == null) { 55 + return; 56 + } 57 + 58 + context.read<ProfileBloc>().add(ProfileLoadRequested(actor: actor)); 59 + context.read<FeedBloc>().add(FeedLoadRequested(actor: actor, filter: filter ?? _currentFilter)); 60 + } 61 + 62 + String? get _resolvedActor { 63 + final authState = context.read<AuthBloc>().state; 64 + if (!authState.isAuthenticated) { 65 + return null; 66 + } 67 + 68 + return widget.actor ?? authState.tokens?.did; 69 + } 70 + 71 + FeedFilter get _currentFilter => _tabs[_tabController.index].filter; 72 + 73 + Future<void> _refresh() async { 74 + context.read<ProfileBloc>().add(const ProfileRefreshRequested()); 75 + context.read<FeedBloc>().add(const FeedRefreshRequested()); 76 + await Future<void>.delayed(const Duration(milliseconds: 250)); 77 + } 7 78 8 79 @override 9 80 Widget build(BuildContext context) { 10 - final state = context.watch<AuthBloc>().state; 11 - final tokens = state.tokens; 81 + return Scaffold( 82 + body: BlocBuilder<ProfileBloc, ProfileState>( 83 + builder: (context, profileState) { 84 + return BlocBuilder<FeedBloc, FeedState>( 85 + builder: (context, feedState) { 86 + final profile = profileState.profile; 87 + 88 + return NestedScrollView( 89 + headerSliverBuilder: (context, innerBoxIsScrolled) { 90 + return [ 91 + SliverAppBar( 92 + expandedHeight: 220, 93 + pinned: true, 94 + stretch: true, 95 + flexibleSpace: FlexibleSpaceBar( 96 + title: Text(profile?.displayName ?? profile?.handle ?? 'Profile'), 97 + background: _buildBanner(profile), 98 + ), 99 + leading: IconButton( 100 + icon: const Icon(Icons.arrow_back), 101 + onPressed: () => context.canPop() ? context.pop() : context.go('/'), 102 + ), 103 + actions: [ 104 + IconButton( 105 + icon: const Icon(Icons.settings_outlined), 106 + onPressed: () => context.push('/settings'), 107 + ), 108 + ], 109 + ), 110 + SliverToBoxAdapter( 111 + child: switch (profileState.status) { 112 + ProfileStatus.loading => const Padding( 113 + padding: EdgeInsets.all(24), 114 + child: Center(child: CircularProgressIndicator()), 115 + ), 116 + ProfileStatus.error => _buildProfileError(context, profileState.errorMessage), 117 + _ => _buildProfileSummary(context, profile), 118 + }, 119 + ), 120 + SliverPersistentHeader( 121 + pinned: true, 122 + delegate: _SliverTabBarDelegate( 123 + TabBar( 124 + controller: _tabController, 125 + tabs: [for (final tab in _tabs) Tab(text: tab.label)], 126 + onTap: (index) => _loadProfileAndFeed(filter: _tabs[index].filter), 127 + ), 128 + ), 129 + ), 130 + ]; 131 + }, 132 + body: TabBarView( 133 + controller: _tabController, 134 + children: [for (var i = 0; i < _tabs.length; i++) _buildFeedList(feedState, _tabs[i].filter)], 135 + ), 136 + ); 137 + }, 138 + ); 139 + }, 140 + ), 141 + ); 142 + } 12 143 13 - return Scaffold( 14 - appBar: AppBar(title: const Text('Profile')), 15 - body: tokens == null 16 - ? const Center(child: Text('No active account')) 17 - : Padding( 18 - padding: const EdgeInsets.all(24), 19 - child: Column( 20 - crossAxisAlignment: CrossAxisAlignment.start, 21 - children: [ 22 - Text(tokens.displayName ?? tokens.handle, style: Theme.of(context).textTheme.headlineMedium), 23 - const SizedBox(height: 8), 24 - Text('@${tokens.handle}', style: Theme.of(context).textTheme.titleMedium), 25 - const SizedBox(height: 12), 26 - SelectableText(tokens.did, style: Theme.of(context).textTheme.bodyMedium), 27 - const SizedBox(height: 24), 28 - Text( 29 - 'Phase 1 milestone 0 requires the profile route and feature structure. Full profile rendering is scheduled in milestone 2.', 30 - style: Theme.of(context).textTheme.bodyMedium, 31 - ), 32 - ], 33 - ), 144 + Widget _buildBanner(ProfileViewDetailed? profile) { 145 + if (profile?.banner == null) { 146 + return DecoratedBox( 147 + decoration: BoxDecoration( 148 + gradient: LinearGradient( 149 + colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400], 150 + begin: Alignment.topLeft, 151 + end: Alignment.bottomRight, 152 + ), 153 + ), 154 + ); 155 + } 156 + 157 + return Image.network( 158 + profile!.banner!, 159 + fit: BoxFit.cover, 160 + errorBuilder: (_, _, _) => DecoratedBox( 161 + decoration: BoxDecoration( 162 + gradient: LinearGradient( 163 + colors: [Colors.blueGrey.shade700, Colors.blueGrey.shade400], 164 + begin: Alignment.topLeft, 165 + end: Alignment.bottomRight, 166 + ), 167 + ), 168 + ), 169 + ); 170 + } 171 + 172 + Widget _buildProfileError(BuildContext context, String? errorMessage) { 173 + return Padding( 174 + padding: const EdgeInsets.all(24), 175 + child: Column( 176 + crossAxisAlignment: CrossAxisAlignment.start, 177 + children: [ 178 + Text('Unable to load profile', style: Theme.of(context).textTheme.titleLarge), 179 + const SizedBox(height: 8), 180 + Text(errorMessage ?? 'Unknown error', style: Theme.of(context).textTheme.bodyMedium), 181 + const SizedBox(height: 12), 182 + FilledButton(onPressed: _loadProfileAndFeed, child: const Text('Try again')), 183 + ], 184 + ), 185 + ); 186 + } 187 + 188 + Widget _buildProfileSummary(BuildContext context, ProfileViewDetailed? profile) { 189 + if (profile == null) { 190 + return const SizedBox.shrink(); 191 + } 192 + 193 + final metaChildren = <Widget>[ 194 + if (profile.pronouns?.isNotEmpty ?? false) 195 + _buildMetaChip(context, Icons.record_voice_over_outlined, profile.pronouns!), 196 + if (profile.website?.isNotEmpty ?? false) 197 + _buildMetaChip(context, Icons.link_outlined, profile.website!, onTap: () => _launchWebsite(profile.website!)), 198 + if (profile.createdAt != null) 199 + _buildMetaChip( 200 + context, 201 + Icons.calendar_today_outlined, 202 + 'Joined ${DateFormat.yMMMM().format(profile.createdAt!)}', 203 + ), 204 + ]; 205 + 206 + return Padding( 207 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), 208 + child: Column( 209 + crossAxisAlignment: CrossAxisAlignment.start, 210 + children: [ 211 + Transform.translate(offset: const Offset(0, -36), child: _buildAvatar(profile)), 212 + Text( 213 + profile.displayName ?? profile.handle, 214 + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), 215 + ), 216 + const SizedBox(height: 4), 217 + Text( 218 + '@${profile.handle}', 219 + style: Theme.of( 220 + context, 221 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 222 + ), 223 + if (profile.description?.isNotEmpty ?? false) ...[ 224 + const SizedBox(height: 12), 225 + Text(profile.description!, style: Theme.of(context).textTheme.bodyLarge), 226 + ], 227 + if (metaChildren.isNotEmpty) ...[ 228 + const SizedBox(height: 16), 229 + Wrap(spacing: 8, runSpacing: 8, children: metaChildren), 230 + ], 231 + const SizedBox(height: 16), 232 + Wrap( 233 + spacing: 16, 234 + runSpacing: 8, 235 + children: [ 236 + _buildStat(context, profile.followsCount ?? 0, 'Following'), 237 + _buildStat(context, profile.followersCount ?? 0, 'Followers'), 238 + _buildStat(context, profile.postsCount ?? 0, 'Posts'), 239 + ], 240 + ), 241 + ], 242 + ), 243 + ); 244 + } 245 + 246 + Widget _buildAvatar(ProfileViewDetailed profile) { 247 + final avatarUrl = profile.avatar; 248 + 249 + return Container( 250 + width: 96, 251 + height: 96, 252 + decoration: BoxDecoration( 253 + shape: BoxShape.circle, 254 + border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 4), 255 + ), 256 + child: CircleAvatar( 257 + radius: 44, 258 + backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, 259 + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, 260 + child: avatarUrl == null 261 + ? Text(_initials(profile.displayName ?? profile.handle), style: Theme.of(context).textTheme.headlineSmall) 262 + : null, 263 + ), 264 + ); 265 + } 266 + 267 + Widget _buildMetaChip(BuildContext context, IconData icon, String label, {VoidCallback? onTap}) { 268 + final chip = Container( 269 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 270 + decoration: BoxDecoration( 271 + color: Theme.of(context).colorScheme.surfaceContainerHighest, 272 + borderRadius: BorderRadius.circular(999), 273 + ), 274 + child: Row( 275 + mainAxisSize: MainAxisSize.min, 276 + children: [ 277 + Icon(icon, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), 278 + const SizedBox(width: 6), 279 + Flexible( 280 + child: Text( 281 + label, 282 + style: Theme.of( 283 + context, 284 + ).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 34 285 ), 286 + ), 287 + ], 288 + ), 35 289 ); 290 + 291 + if (onTap == null) { 292 + return chip; 293 + } 294 + 295 + return InkWell(onTap: onTap, borderRadius: BorderRadius.circular(999), child: chip); 36 296 } 297 + 298 + Widget _buildStat(BuildContext context, int count, String label) { 299 + return RichText( 300 + text: TextSpan( 301 + style: Theme.of(context).textTheme.bodyMedium, 302 + children: [ 303 + TextSpan( 304 + text: _formatCount(count), 305 + style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w700), 306 + ), 307 + TextSpan( 308 + text: ' $label', 309 + style: Theme.of( 310 + context, 311 + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), 312 + ), 313 + ], 314 + ), 315 + ); 316 + } 317 + 318 + Widget _buildFeedList(FeedState feedState, FeedFilter tabFilter) { 319 + if (feedState.isLoading && feedState.filter == tabFilter) { 320 + return const Center(child: CircularProgressIndicator()); 321 + } 322 + 323 + if (feedState.hasError && feedState.filter == tabFilter) { 324 + return Center(child: Text(feedState.errorMessage ?? 'Failed to load posts')); 325 + } 326 + 327 + if (feedState.filter != tabFilter) { 328 + return const SizedBox.shrink(); 329 + } 330 + 331 + if (!feedState.hasPosts) { 332 + return Center(child: Text(_emptyLabel(tabFilter))); 333 + } 334 + 335 + return RefreshIndicator( 336 + onRefresh: _refresh, 337 + child: NotificationListener<ScrollNotification>( 338 + onNotification: (notification) { 339 + if (notification.metrics.pixels > notification.metrics.maxScrollExtent - 300 && 340 + feedState.hasMore && 341 + !feedState.isLoadingMore) { 342 + context.read<FeedBloc>().add(const FeedLoadMoreRequested()); 343 + } 344 + 345 + return false; 346 + }, 347 + child: ListView.builder( 348 + padding: EdgeInsets.zero, 349 + itemCount: feedState.posts.length + (feedState.isLoadingMore ? 1 : 0), 350 + itemBuilder: (context, index) { 351 + if (index >= feedState.posts.length) { 352 + return const Padding( 353 + padding: EdgeInsets.all(16), 354 + child: Center(child: CircularProgressIndicator()), 355 + ); 356 + } 357 + 358 + return PostCard(feedViewPost: feedState.posts[index]); 359 + }, 360 + ), 361 + ), 362 + ); 363 + } 364 + 365 + String _emptyLabel(FeedFilter filter) { 366 + switch (filter) { 367 + case FeedFilter.postsNoReplies: 368 + return 'No posts yet'; 369 + case FeedFilter.postsAndAuthorThreads: 370 + return 'No replies or threads yet'; 371 + case FeedFilter.postsWithMedia: 372 + return 'No media posts yet'; 373 + } 374 + } 375 + 376 + String _formatCount(int count) { 377 + if (count >= 1000000) { 378 + return '${(count / 1000000).toStringAsFixed(1)}M'; 379 + } 380 + 381 + if (count >= 1000) { 382 + return '${(count / 1000).toStringAsFixed(1)}K'; 383 + } 384 + 385 + return '$count'; 386 + } 387 + 388 + String _initials(String value) { 389 + final parts = value.trim().split(RegExp(r'\s+')); 390 + if (parts.isEmpty || parts.first.isEmpty) { 391 + return '?'; 392 + } 393 + 394 + if (parts.length == 1) { 395 + return parts.first.substring(0, 1).toUpperCase(); 396 + } 397 + 398 + return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'.toUpperCase(); 399 + } 400 + 401 + Future<void> _launchWebsite(String website) async { 402 + final uri = Uri.tryParse(website.startsWith('http') ? website : 'https://$website'); 403 + if (uri == null) { 404 + return; 405 + } 406 + 407 + await launchUrl(uri, mode: LaunchMode.externalApplication); 408 + } 409 + } 410 + 411 + class _SliverTabBarDelegate extends SliverPersistentHeaderDelegate { 412 + _SliverTabBarDelegate(this.tabBar); 413 + 414 + final TabBar tabBar; 415 + 416 + @override 417 + double get minExtent => tabBar.preferredSize.height; 418 + 419 + @override 420 + double get maxExtent => tabBar.preferredSize.height; 421 + 422 + @override 423 + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { 424 + return ColoredBox(color: Theme.of(context).scaffoldBackgroundColor, child: tabBar); 425 + } 426 + 427 + @override 428 + bool shouldRebuild(_SliverTabBarDelegate oldDelegate) => false; 37 429 }
+70 -7
lib/main.dart
··· 1 + import 'package:atproto_core/atproto_core.dart' as atp_core; 2 + import 'package:bluesky/bluesky.dart'; 1 3 import 'package:flutter/material.dart'; 2 4 import 'package:flutter_bloc/flutter_bloc.dart'; 3 5 import 'package:lazurite/core/database/app_database.dart'; 4 6 import 'package:lazurite/core/router/app_router.dart'; 5 7 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 8 import 'package:lazurite/features/auth/data/auth_repository.dart'; 9 + import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 10 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 11 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 12 + import 'package:lazurite/features/profile/data/profile_repository.dart'; 7 13 8 14 Future<void> main() async { 9 15 WidgetsFlutterBinding.ensureInitialized(); ··· 18 24 : const AuthState.unauthenticated(), 19 25 ); 20 26 21 - runApp(LazuriteApp(authBloc: authBloc)); 27 + runApp(LazuriteApp(authBloc: authBloc, database: database)); 22 28 } 23 29 24 30 class LazuriteApp extends StatelessWidget { 25 - const LazuriteApp({super.key, required this.authBloc}); 31 + const LazuriteApp({super.key, required this.authBloc, required this.database}); 26 32 27 33 final AuthBloc authBloc; 34 + final AppDatabase database; 35 + 36 + Bluesky? _createBluesky(AuthState state) { 37 + if (!state.isAuthenticated || state.tokens == null) { 38 + return null; 39 + } 40 + 41 + final tokens = state.tokens!; 42 + final service = tokens.service ?? 'bsky.social'; 43 + 44 + if (tokens.usesOAuth) { 45 + if (tokens.dpopPublicKey == null || tokens.dpopPrivateKey == null || tokens.refreshToken == null) { 46 + return null; 47 + } 48 + 49 + final oauthSession = atp_core.restoreOAuthSession( 50 + accessToken: tokens.accessToken, 51 + refreshToken: tokens.refreshToken!, 52 + dPoPNonce: tokens.dpopNonce, 53 + publicKey: tokens.dpopPublicKey!, 54 + privateKey: tokens.dpopPrivateKey!, 55 + ); 56 + return Bluesky.fromOAuthSession(oauthSession, service: service); 57 + } 58 + 59 + if (tokens.refreshToken == null) { 60 + return null; 61 + } 62 + 63 + final session = atp_core.Session( 64 + did: tokens.did, 65 + handle: tokens.handle, 66 + accessJwt: tokens.accessToken, 67 + refreshJwt: tokens.refreshToken!, 68 + ); 69 + return Bluesky.fromSession(session, service: service); 70 + } 28 71 29 72 @override 30 73 Widget build(BuildContext context) { ··· 32 75 33 76 return BlocProvider.value( 34 77 value: authBloc, 35 - child: MaterialApp.router( 36 - title: 'Lazurite', 37 - debugShowCheckedModeBanner: false, 38 - theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true), 39 - routerConfig: router, 78 + child: BlocBuilder<AuthBloc, AuthState>( 79 + builder: (context, authState) { 80 + final bluesky = _createBluesky(authState); 81 + 82 + return MultiBlocProvider( 83 + providers: [ 84 + if (bluesky != null) ...[ 85 + BlocProvider( 86 + create: (_) => ProfileBloc( 87 + profileRepository: ProfileRepository(database: database, bluesky: bluesky), 88 + ), 89 + ), 90 + BlocProvider( 91 + create: (_) => FeedBloc(feedRepository: FeedRepository(bluesky: bluesky)), 92 + ), 93 + ], 94 + ], 95 + child: MaterialApp.router( 96 + title: 'Lazurite', 97 + debugShowCheckedModeBanner: false, 98 + theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true), 99 + routerConfig: router, 100 + ), 101 + ); 102 + }, 40 103 ), 41 104 ); 42 105 }
+8
pubspec.lock
··· 472 472 url: "https://pub.dev" 473 473 source: hosted 474 474 version: "4.1.2" 475 + intl: 476 + dependency: "direct main" 477 + description: 478 + name: intl 479 + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf 480 + url: "https://pub.dev" 481 + source: hosted 482 + version: "0.19.0" 475 483 io: 476 484 dependency: transitive 477 485 description:
+1
pubspec.yaml
··· 26 26 json_annotation: ^4.9.0 27 27 crypto: ^3.0.6 28 28 url_launcher: ^6.3.1 29 + intl: ^0.19.0 29 30 30 31 dev_dependencies: 31 32 flutter_test:
+100
test/features/feed/bloc/feed_bloc_test.dart
··· 1 + import 'package:atproto_core/atproto_core.dart'; 2 + import 'package:bloc_test/bloc_test.dart'; 3 + import 'package:bluesky/app_bsky_actor_defs.dart'; 4 + import 'package:bluesky/app_bsky_feed_defs.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 7 + import 'package:lazurite/features/feed/data/feed_repository.dart'; 8 + import 'package:mocktail/mocktail.dart'; 9 + 10 + class MockFeedRepository extends Mock implements FeedRepository {} 11 + 12 + void main() { 13 + late MockFeedRepository mockFeedRepository; 14 + 15 + setUp(() { 16 + mockFeedRepository = MockFeedRepository(); 17 + }); 18 + 19 + group('FeedBloc', () { 20 + final samplePost = FeedViewPost( 21 + post: PostView( 22 + uri: const AtUri('at://did:plc:author/app.bsky.feed.post/abc'), 23 + cid: 'cid-123', 24 + author: const ProfileViewBasic(did: 'did:plc:author', handle: 'author.bsky.social'), 25 + record: { 26 + r'$type': 'app.bsky.feed.post', 27 + 'text': 'Hello world', 28 + 'createdAt': DateTime.utc(2026, 3, 15).toIso8601String(), 29 + }, 30 + indexedAt: DateTime.utc(2026, 3, 15), 31 + ), 32 + ); 33 + 34 + blocTest<FeedBloc, FeedState>( 35 + 'loads a feed for the requested actor and filter', 36 + build: () => FeedBloc(feedRepository: mockFeedRepository), 37 + setUp: () { 38 + when( 39 + () => mockFeedRepository.getAuthorFeed(actor: 'did:plc:target', filter: FeedFilter.postsWithMedia, limit: 25), 40 + ).thenAnswer((_) async => FeedResult(posts: [samplePost], cursor: 'cursor-1')); 41 + }, 42 + act: (bloc) => 43 + bloc.add(const FeedLoadRequested(actor: 'did:plc:target', filter: FeedFilter.postsWithMedia, limit: 25)), 44 + expect: () => [ 45 + const FeedState.loading(actor: 'did:plc:target', filter: FeedFilter.postsWithMedia), 46 + predicate<FeedState>( 47 + (state) => 48 + state.status == FeedStatus.loaded && 49 + state.actor == 'did:plc:target' && 50 + state.filter == FeedFilter.postsWithMedia && 51 + state.cursor == 'cursor-1' && 52 + state.posts.length == 1, 53 + ), 54 + ], 55 + ); 56 + 57 + blocTest<FeedBloc, FeedState>( 58 + 'uses the tracked actor for pagination instead of deriving it from returned posts', 59 + build: () => FeedBloc(feedRepository: mockFeedRepository), 60 + seed: () => FeedState.loaded( 61 + actor: 'did:plc:requested', 62 + posts: [samplePost], 63 + cursor: 'cursor-2', 64 + filter: FeedFilter.postsAndAuthorThreads, 65 + hasMore: true, 66 + ), 67 + setUp: () { 68 + when( 69 + () => mockFeedRepository.getAuthorFeed( 70 + actor: 'did:plc:requested', 71 + filter: FeedFilter.postsAndAuthorThreads, 72 + cursor: 'cursor-2', 73 + limit: 50, 74 + ), 75 + ).thenAnswer((_) async => FeedResult(posts: const [], cursor: null)); 76 + }, 77 + act: (bloc) => bloc.add(const FeedLoadMoreRequested()), 78 + expect: () => [ 79 + predicate<FeedState>((state) => state.isLoadingMore && state.actor == 'did:plc:requested'), 80 + predicate<FeedState>( 81 + (state) => 82 + state.status == FeedStatus.loaded && 83 + state.actor == 'did:plc:requested' && 84 + !state.isLoadingMore && 85 + !state.hasMore, 86 + ), 87 + ], 88 + verify: (_) { 89 + verify( 90 + () => mockFeedRepository.getAuthorFeed( 91 + actor: 'did:plc:requested', 92 + filter: FeedFilter.postsAndAuthorThreads, 93 + cursor: 'cursor-2', 94 + limit: 50, 95 + ), 96 + ).called(1); 97 + }, 98 + ); 99 + }); 100 + }
+82
test/features/feed/presentation/post_card_test.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:atproto_core/atproto_core.dart'; 4 + import 'package:bluesky/app_bsky_actor_defs.dart'; 5 + import 'package:bluesky/app_bsky_embed_external.dart'; 6 + import 'package:bluesky/app_bsky_feed_defs.dart'; 7 + import 'package:bluesky/app_bsky_feed_post.dart'; 8 + import 'package:bluesky/app_bsky_richtext_facet.dart'; 9 + import 'package:flutter/material.dart'; 10 + import 'package:flutter_test/flutter_test.dart'; 11 + import 'package:lazurite/features/feed/presentation/widgets/post_card.dart'; 12 + 13 + void main() { 14 + Widget buildSubject(FeedViewPost post) { 15 + return MaterialApp( 16 + home: Scaffold(body: PostCard(feedViewPost: post)), 17 + ); 18 + } 19 + 20 + testWidgets('renders UTF-8 facet ranges without corrupting text', (tester) async { 21 + const text = 'Launch 🚀 #tag'; 22 + final start = utf8.encode('Launch 🚀 ').length; 23 + final end = utf8.encode(text).length; 24 + 25 + final record = FeedPostRecord( 26 + text: text, 27 + createdAt: DateTime.utc(2026, 3, 16), 28 + facets: [ 29 + RichtextFacet( 30 + index: RichtextFacetByteSlice(byteStart: start, byteEnd: end), 31 + features: const [URichtextFacetFeatures.richtextFacetTag(data: RichtextFacetTag(tag: 'tag'))], 32 + ), 33 + ], 34 + ); 35 + 36 + final post = FeedViewPost( 37 + post: PostView( 38 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 39 + cid: 'cid-xyz', 40 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 41 + record: record.toJson(), 42 + indexedAt: DateTime.utc(2026, 3, 16), 43 + ), 44 + ); 45 + 46 + await tester.pumpWidget(buildSubject(post)); 47 + 48 + final richTextFinder = find.byWidgetPredicate( 49 + (widget) => widget is RichText && widget.text.toPlainText() == 'Launch 🚀 #tag', 50 + ); 51 + 52 + expect(richTextFinder, findsOneWidget); 53 + }); 54 + 55 + testWidgets('renders external link card embeds', (tester) async { 56 + final record = FeedPostRecord(text: 'Read this', createdAt: DateTime.utc(2026, 3, 16)); 57 + final post = FeedViewPost( 58 + post: PostView( 59 + uri: const AtUri('at://did:plc:test/app.bsky.feed.post/xyz'), 60 + cid: 'cid-xyz', 61 + author: const ProfileViewBasic(did: 'did:plc:test', handle: 'test.bsky.social'), 62 + record: record.toJson(), 63 + indexedAt: DateTime.utc(2026, 3, 16), 64 + embed: const UPostViewEmbed.embedExternalView( 65 + data: EmbedExternalView( 66 + external: EmbedExternalViewExternal( 67 + uri: 'https://example.com/article', 68 + title: 'Example Article', 69 + description: 'A useful external card', 70 + ), 71 + ), 72 + ), 73 + ), 74 + ); 75 + 76 + await tester.pumpWidget(buildSubject(post)); 77 + 78 + expect(find.text('Example Article'), findsOneWidget); 79 + expect(find.text('A useful external card'), findsOneWidget); 80 + expect(find.text('example.com'), findsOneWidget); 81 + }); 82 + }
+113
test/features/profile/presentation/profile_screen_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:bluesky/app_bsky_actor_defs.dart'; 3 + import 'package:flutter/material.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:flutter_test/flutter_test.dart'; 6 + import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 7 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 8 + import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; 9 + import 'package:lazurite/features/profile/bloc/profile_bloc.dart'; 10 + import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 11 + import 'package:mocktail/mocktail.dart'; 12 + 13 + class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 14 + 15 + class MockProfileBloc extends MockBloc<ProfileEvent, ProfileState> implements ProfileBloc {} 16 + 17 + class MockFeedBloc extends MockBloc<FeedEvent, FeedState> implements FeedBloc {} 18 + 19 + void main() { 20 + late MockAuthBloc authBloc; 21 + late MockProfileBloc profileBloc; 22 + late MockFeedBloc feedBloc; 23 + 24 + const tokens = AuthTokens( 25 + accessToken: 'access', 26 + refreshToken: 'refresh', 27 + did: 'did:plc:me', 28 + handle: 'me.bsky.social', 29 + ); 30 + 31 + final profile = ProfileViewDetailed( 32 + did: 'did:plc:me', 33 + handle: 'me.bsky.social', 34 + displayName: 'River Tam', 35 + description: 'Signal and signal boost.', 36 + pronouns: 'she/her', 37 + website: 'river.example', 38 + followersCount: 1200, 39 + followsCount: 64, 40 + postsCount: 512, 41 + createdAt: DateTime.utc(2024, 3, 1), 42 + ); 43 + 44 + setUp(() { 45 + authBloc = MockAuthBloc(); 46 + profileBloc = MockProfileBloc(); 47 + feedBloc = MockFeedBloc(); 48 + 49 + when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); 50 + when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); 51 + when(() => feedBloc.state).thenReturn( 52 + const FeedState.loaded(actor: 'did:plc:me', posts: [], filter: FeedFilter.postsNoReplies, hasMore: false), 53 + ); 54 + 55 + whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.authenticated(tokens)); 56 + whenListen(profileBloc, const Stream<ProfileState>.empty(), initialState: ProfileState.loaded(profile: profile)); 57 + whenListen( 58 + feedBloc, 59 + const Stream<FeedState>.empty(), 60 + initialState: const FeedState.loaded( 61 + actor: 'did:plc:me', 62 + posts: [], 63 + filter: FeedFilter.postsNoReplies, 64 + hasMore: false, 65 + ), 66 + ); 67 + }); 68 + 69 + Widget buildSubject() { 70 + return MultiBlocProvider( 71 + providers: [ 72 + BlocProvider<AuthBloc>.value(value: authBloc), 73 + BlocProvider<ProfileBloc>.value(value: profileBloc), 74 + BlocProvider<FeedBloc>.value(value: feedBloc), 75 + ], 76 + child: const MaterialApp(home: ProfileScreen()), 77 + ); 78 + } 79 + 80 + testWidgets('loads posts filter by default and renders the required profile fields', (tester) async { 81 + await tester.pumpWidget(buildSubject()); 82 + 83 + verify(() => profileBloc.add(const ProfileLoadRequested(actor: 'did:plc:me'))).called(1); 84 + verify( 85 + () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsNoReplies)), 86 + ).called(1); 87 + 88 + expect(find.text('River Tam'), findsAtLeastNWidgets(1)); 89 + expect(find.text('@me.bsky.social'), findsOneWidget); 90 + expect(find.text('Signal and signal boost.'), findsOneWidget); 91 + expect(find.text('she/her'), findsOneWidget); 92 + expect(find.text('river.example'), findsOneWidget); 93 + expect(find.text('Joined March 2024'), findsOneWidget); 94 + }); 95 + 96 + testWidgets('maps tabs to the expected server filters', (tester) async { 97 + await tester.pumpWidget(buildSubject()); 98 + 99 + await tester.tap(find.text('Replies')); 100 + await tester.pump(); 101 + 102 + verify( 103 + () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsAndAuthorThreads)), 104 + ).called(1); 105 + 106 + await tester.tap(find.text('Media')); 107 + await tester.pump(); 108 + 109 + verify( 110 + () => feedBloc.add(const FeedLoadRequested(actor: 'did:plc:me', filter: FeedFilter.postsWithMedia)), 111 + ).called(1); 112 + }); 113 + }