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: add account switcher to the login screen (#21)

* docs: updated login screen design

* feat: streamline TypeaheadTextField and button styling in login screen

* feat: add account switcher to login screen

* feat: restore or reauth on account tap

* feat: add assistive labels to login screen

* better error messaging

authored by

Owais and committed by
GitHub
5dd35ca1 5d8eb133

+1317 -159
+551 -87
docs/designs/login.html
··· 14 14 min-height: 100vh; 15 15 display: flex; 16 16 flex-direction: column; 17 - justify-content: center; 18 - padding: 24px; 19 17 background: linear-gradient(180deg, var(--bg) 0%, var(--surface) 100%); 20 18 } 21 19 22 - .login-header { 23 - text-align: center; 24 - margin-bottom: 48px; 20 + .app-bar { 21 + display: flex; 22 + align-items: center; 23 + justify-content: flex-end; 24 + padding: 8px 8px 0; 25 + min-height: 56px; 25 26 } 26 27 27 - .logo { 28 - width: 80px; 29 - height: 80px; 28 + .icon-btn { 29 + width: 40px; 30 + height: 40px; 31 + border: none; 32 + background: transparent; 33 + border-radius: 50%; 34 + cursor: pointer; 35 + display: flex; 36 + align-items: center; 37 + justify-content: center; 38 + color: var(--text-primary); 39 + transition: background 0.15s; 40 + } 41 + .icon-btn:hover { background: var(--surface-variant); } 42 + .icon-btn svg { width: 22px; height: 22px; } 43 + 44 + .login-body { 45 + flex: 1; 46 + display: flex; 47 + justify-content: center; 48 + overflow-y: auto; 49 + padding: 0 24px 24px; 50 + } 51 + 52 + .login-content { 53 + width: 100%; 54 + max-width: 440px; 55 + display: flex; 56 + flex-direction: column; 57 + } 58 + 59 + .logo-card { 60 + width: 88px; 61 + height: 88px; 30 62 background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); 31 - border-radius: 20px; 32 - margin: 0 auto 24px; 63 + border-radius: 24px; 64 + margin: 16px auto 0; 33 65 display: flex; 34 66 align-items: center; 35 67 justify-content: center; 36 - box-shadow: 0 8px 32px rgba(0, 102, 255, 0.25); 68 + box-shadow: 0 12px 28px rgba(0, 102, 255, 0.24); 37 69 } 38 70 39 - .logo svg { 71 + .logo-card svg { 40 72 width: 44px; 41 73 height: 44px; 42 74 color: white; ··· 46 78 font-size: 32px; 47 79 font-weight: 700; 48 80 color: var(--text-primary); 49 - margin-bottom: 8px; 81 + text-align: center; 82 + margin: 24px 0 0; 50 83 letter-spacing: -0.5px; 51 84 } 52 85 53 86 .app-tagline { 54 87 color: var(--text-secondary); 55 88 font-size: 15px; 89 + text-align: center; 90 + margin: 8px 0 32px; 56 91 } 57 92 58 - .login-methods { 93 + .provider-section { 59 94 display: flex; 60 95 flex-direction: column; 61 - gap: 16px; 62 - margin-bottom: 32px; 96 + align-items: center; 97 + gap: 8px; 98 + margin-bottom: 20px; 63 99 } 64 100 65 - .divider-with-text { 101 + .provider-label { 102 + font-size: 13px; 103 + font-weight: 600; 104 + color: var(--text-primary); 105 + text-transform: none; 106 + } 107 + 108 + .segmented-control { 109 + display: flex; 110 + background: var(--surface); 111 + border: 1.5px solid var(--border); 112 + border-radius: 50px; 113 + padding: 3px; 114 + gap: 2px; 115 + } 116 + 117 + .segment { 66 118 display: flex; 67 119 align-items: center; 68 - gap: 16px; 69 - margin: 24px 0; 120 + gap: 6px; 121 + padding: 8px 18px; 122 + border-radius: 50px; 123 + border: none; 124 + background: transparent; 125 + cursor: pointer; 126 + font-size: 13px; 127 + font-weight: 500; 128 + color: var(--text-secondary); 129 + font-family: inherit; 130 + transition: background 0.15s, color 0.15s; 131 + } 132 + 133 + .segment.active { 134 + background: var(--accent-primary); 135 + color: #fff; 136 + } 137 + 138 + .segment svg { 139 + width: 16px; 140 + height: 16px; 141 + } 142 + 143 + .search-bar { 144 + display: flex; 145 + align-items: center; 146 + border: 1.5px solid var(--border); 147 + border-radius: 14px; 148 + background: var(--bg); 149 + transition: border-color 0.15s, box-shadow 0.15s; 150 + overflow: visible; 151 + margin-bottom: 0; 152 + } 153 + 154 + .search-bar:focus-within { 155 + border-color: var(--accent-primary); 156 + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.12); 157 + } 158 + 159 + .search-bar .input-icon { 160 + flex-shrink: 0; 161 + width: 20px; 162 + height: 20px; 163 + margin-left: 14px; 70 164 color: var(--text-muted); 165 + } 166 + 167 + .search-bar .input { 168 + flex: 1; 169 + border: none !important; 170 + outline: none !important; 171 + background: transparent; 172 + box-shadow: none !important; 173 + padding: 14px 10px; 174 + font-size: 15px; 175 + } 176 + 177 + .search-bar .input:focus { 178 + border: none; 179 + box-shadow: none; 180 + } 181 + 182 + .btn-continue { 183 + flex-shrink: 0; 184 + width: 40px; 185 + height: 40px; 186 + margin: 5px; 187 + border-radius: 10px; 188 + background: var(--accent-primary); 189 + border: none; 190 + color: white; 191 + cursor: pointer; 192 + display: flex; 193 + align-items: center; 194 + justify-content: center; 195 + transition: background 0.15s, transform 0.1s; 196 + } 197 + 198 + .btn-continue:hover { background: var(--accent-primary-hover); } 199 + .btn-continue:active { transform: scale(0.92); } 200 + .btn-continue svg { width: 18px; height: 18px; } 201 + 202 + .typeahead-wrapper { 203 + position: relative; 204 + margin-bottom: 20px; 205 + } 206 + 207 + .typeahead-dropdown { 208 + position: absolute; 209 + bottom: calc(100% + 6px); 210 + left: 0; 211 + right: 0; 212 + background: var(--bg); 213 + border: 1.5px solid var(--border); 214 + border-radius: 12px; 215 + box-shadow: 0 -4px 24px rgba(0,0,0,0.10); 216 + z-index: 10; 217 + overflow: hidden; 218 + } 219 + 220 + .typeahead-item { 221 + display: flex; 222 + align-items: center; 223 + gap: 10px; 224 + padding: 10px 14px; 225 + cursor: pointer; 226 + transition: background 0.1s; 227 + } 228 + 229 + .typeahead-item:hover { background: var(--surface); } 230 + 231 + .typeahead-avatar { 232 + width: 32px; 233 + height: 32px; 234 + border-radius: 50%; 235 + background: var(--surface-variant); 236 + flex-shrink: 0; 237 + overflow: hidden; 238 + } 239 + 240 + .typeahead-avatar img { 241 + width: 100%; 242 + height: 100%; 243 + object-fit: cover; 244 + } 245 + 246 + .typeahead-handle { 71 247 font-size: 13px; 248 + color: var(--text-primary); 72 249 font-weight: 500; 250 + } 251 + 252 + .typeahead-display-name { 253 + font-size: 12px; 254 + color: var(--text-secondary); 255 + } 256 + 257 + .account-switcher { 258 + margin-top: 28px; 259 + } 260 + 261 + .account-switcher-header { 262 + font-size: 12px; 263 + font-weight: 600; 264 + color: var(--text-muted); 73 265 text-transform: uppercase; 266 + letter-spacing: 0.06em; 267 + margin-bottom: 10px; 268 + } 269 + 270 + .account-list { 271 + display: flex; 272 + flex-direction: column; 273 + gap: 6px; 274 + } 275 + 276 + .account-item { 277 + display: flex; 278 + align-items: center; 279 + gap: 12px; 280 + padding: 12px 14px; 281 + background: var(--surface); 282 + border-radius: 12px; 283 + cursor: pointer; 284 + border: 1.5px solid transparent; 285 + transition: border-color 0.15s, background 0.15s; 286 + } 287 + 288 + .account-item:hover { 289 + border-color: var(--border); 290 + background: var(--bg); 291 + } 292 + 293 + .account-avatar { 294 + width: 40px; 295 + height: 40px; 296 + border-radius: 50%; 297 + background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); 298 + flex-shrink: 0; 299 + display: flex; 300 + align-items: center; 301 + justify-content: center; 302 + color: white; 303 + font-size: 15px; 304 + font-weight: 700; 305 + } 306 + 307 + .account-info { 308 + flex: 1; 309 + min-width: 0; 310 + } 311 + 312 + .account-display-name { 313 + font-size: 14px; 314 + font-weight: 600; 315 + color: var(--text-primary); 316 + white-space: nowrap; 317 + overflow: hidden; 318 + text-overflow: ellipsis; 319 + } 320 + 321 + .account-handle { 322 + font-size: 12px; 323 + color: var(--text-secondary); 324 + white-space: nowrap; 325 + overflow: hidden; 326 + text-overflow: ellipsis; 327 + } 328 + 329 + .account-remove { 330 + width: 28px; 331 + height: 28px; 332 + border: none; 333 + background: transparent; 334 + border-radius: 50%; 335 + cursor: pointer; 336 + display: flex; 337 + align-items: center; 338 + justify-content: center; 339 + color: var(--text-muted); 340 + flex-shrink: 0; 341 + transition: background 0.1s, color 0.1s; 342 + } 343 + .account-remove:hover { background: var(--surface-variant); color: var(--accent-error); } 344 + .account-remove svg { width: 16px; height: 16px; } 345 + 346 + .divider-with-text { 347 + display: flex; 348 + align-items: center; 349 + gap: 16px; 350 + margin: 24px 0 16px; 351 + color: var(--text-muted); 352 + font-size: 12px; 353 + font-weight: 600; 354 + text-transform: uppercase; 355 + letter-spacing: 0.06em; 74 356 } 75 357 76 358 .divider-with-text::before, ··· 82 364 } 83 365 84 366 .debug-section { 85 - background-color: var(--surface); 367 + border: 1.5px solid var(--border); 86 368 border-radius: 12px; 87 - padding: 20px; 88 - border: 1.5px dashed var(--border); 369 + overflow: hidden; 89 370 } 90 371 91 372 .debug-header { 92 373 display: flex; 93 374 align-items: center; 94 375 gap: 8px; 95 - margin-bottom: 16px; 376 + padding: 14px 16px; 96 377 } 97 378 379 + .debug-icon { 380 + color: var(--text-secondary); 381 + } 382 + .debug-icon svg { width: 20px; height: 20px; } 383 + 98 384 .debug-title { 99 385 font-weight: 600; 100 386 color: var(--text-primary); 101 387 font-size: 15px; 388 + flex: 1; 102 389 } 103 390 104 - .debug-form { 105 - display: flex; 106 - flex-direction: column; 107 - gap: 12px; 391 + .debug-toggle { 392 + border: none; 393 + background: transparent; 394 + color: var(--accent-primary); 395 + font-size: 14px; 396 + font-weight: 500; 397 + font-family: inherit; 398 + cursor: pointer; 399 + padding: 4px 8px; 400 + border-radius: 6px; 401 + transition: background 0.1s; 108 402 } 403 + .debug-toggle:hover { background: var(--surface); } 109 404 110 - .input-group { 405 + .debug-body { 406 + padding: 0 16px 16px; 111 407 display: flex; 112 408 flex-direction: column; 113 - gap: 6px; 409 + gap: 12px; 114 410 } 115 411 116 - .input-label { 117 - font-size: 13px; 118 - font-weight: 500; 119 - color: var(--text-secondary); 120 - text-transform: uppercase; 121 - } 412 + .debug-body.hidden { display: none; } 122 413 123 414 .help-text { 124 415 font-size: 12px; 125 416 color: var(--text-muted); 126 - margin-top: 8px; 127 417 text-align: center; 418 + line-height: 1.5; 128 419 } 129 420 130 421 .login-footer { 131 - text-align: center; 132 - margin-top: auto; 133 - padding-top: 32px; 422 + display: flex; 423 + justify-content: center; 424 + align-items: center; 425 + gap: 8px; 426 + margin-top: 28px; 427 + padding-bottom: 8px; 428 + flex-wrap: wrap; 134 429 } 135 430 136 - .terms-text { 431 + .footer-btn { 432 + border: none; 433 + background: transparent; 434 + color: var(--accent-primary); 435 + font-size: 13px; 436 + font-weight: 500; 437 + font-family: inherit; 438 + cursor: pointer; 439 + padding: 4px 8px; 440 + border-radius: 6px; 441 + transition: background 0.1s; 442 + } 443 + .footer-btn:hover { background: var(--surface); } 444 + 445 + .footer-dot { 137 446 font-size: 12px; 138 447 color: var(--text-muted); 139 - line-height: 1.6; 140 448 } 141 449 </style> 142 450 </head> 143 451 <body> 144 452 <div class="mobile-container"> 145 453 <div class="login-screen"> 146 - <!-- Logo & Header --> 147 - <div class="login-header"> 148 - <div class="logo"> 454 + 455 + <div class="app-bar"> 456 + <button class="icon-btn" title="Settings"> 149 457 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 150 - <path d="M12 2L2 7l10 5 10-5-10-5z"/> 151 - <path d="M2 17l10 5 10-5"/> 152 - <path d="M2 12l10 5 10-5"/> 458 + <circle cx="12" cy="12" r="3"/> 459 + <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.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-4 0v-.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-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.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 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/> 153 460 </svg> 154 - </div> 155 - <h1 class="app-name">Lazurite</h1> 156 - <p class="app-tagline">Roam the ATmosphere</p> 461 + </button> 157 462 </div> 158 463 159 - <!-- Login Methods --> 160 - <div class="login-methods"> 161 - <!-- OAuth 2.0 Login --> 162 - <button class="oauth-btn"> 163 - <svg viewBox="0 0 24 24" fill="currentColor"> 164 - <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/> 165 - </svg> 166 - Continue 167 - </button> 464 + <div class="login-body"> 465 + <div class="login-content"> 168 466 169 - <div class="divider-with-text">Or</div> 467 + <div class="logo-card"> 468 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 469 + <path d="M12 2L2 7l10 5 10-5-10-5z"/> 470 + <path d="M2 17l10 5 10-5"/> 471 + <path d="M2 12l10 5 10-5"/> 472 + </svg> 473 + </div> 170 474 171 - <!-- Debug Mode - App Password --> 172 - <div class="debug-section"> 173 - <div class="debug-header"> 174 - <span class="debug-badge">Debug Mode</span> 175 - <span class="debug-title">App Password Login</span> 475 + <h1 class="app-name">Lazurite</h1> 476 + <p class="app-tagline">Roam the ATmosphere</p> 477 + 478 + <div class="provider-section"> 479 + <span class="provider-label">Choose your portal</span> 480 + <div class="segmented-control"> 481 + <button class="segment active" onclick="selectProvider(this, 'bluesky')"> 482 + <svg viewBox="0 0 24 24" fill="currentColor"> 483 + <path d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm0 2c4.418 0 8 3.582 8 8s-3.582 8-8 8-8-3.582-8-8 3.582-8 8-8z"/> 484 + </svg> 485 + BlueSky 486 + </button> 487 + <button class="segment" onclick="selectProvider(this, 'blacksky')"> 488 + <svg viewBox="0 0 24 24" fill="currentColor"> 489 + <circle cx="12" cy="12" r="8"/> 490 + </svg> 491 + BlackSky 492 + </button> 493 + </div> 494 + </div> 495 + 496 + <div class="typeahead-wrapper"> 497 + <div class="typeahead-dropdown" id="typeahead-dropdown" style="display:none;"> 498 + <div class="typeahead-item" onclick="selectHandle('alice.bsky.social')"> 499 + <div class="typeahead-avatar"> 500 + <svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"> 501 + <circle cx="12" cy="8" r="4" fill="var(--text-muted)"/> 502 + <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" fill="var(--text-muted)"/> 503 + </svg> 504 + </div> 505 + <div> 506 + <div class="typeahead-handle">alice.bsky.social</div> 507 + <div class="typeahead-display-name">Alice</div> 508 + </div> 509 + </div> 510 + <div class="typeahead-item" onclick="selectHandle('alice.arts.bsky.social')"> 511 + <div class="typeahead-avatar"> 512 + <svg viewBox="0 0 24 24" fill="currentColor" width="32" height="32"> 513 + <circle cx="12" cy="8" r="4" fill="var(--text-muted)"/> 514 + <path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" fill="var(--text-muted)"/> 515 + </svg> 516 + </div> 517 + <div> 518 + <div class="typeahead-handle">alice.arts.bsky.social</div> 519 + <div class="typeahead-display-name">Alice Arts</div> 520 + </div> 521 + </div> 522 + </div> 523 + 524 + <div class="search-bar"> 525 + <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 526 + <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> 527 + <circle cx="12" cy="7" r="4"/> 528 + </svg> 529 + <input 530 + type="text" 531 + class="input" 532 + placeholder="username.bsky.social or did:plc:..." 533 + id="handle-input" 534 + autocomplete="off" 535 + oninput="onHandleInput(this)" 536 + > 537 + <button class="btn-continue" id="continue-btn" title="Continue"> 538 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 539 + <line x1="5" y1="12" x2="19" y2="12"/> 540 + <polyline points="12 5 19 12 12 19"/> 541 + </svg> 542 + </button> 543 + </div> 176 544 </div> 177 545 178 - <form class="debug-form"> 179 - <div class="input-group"> 180 - <label class="input-label">Handle or DID</label> 181 - <input type="text" class="input" placeholder="@username.bsky.social"> 546 + <div class="account-switcher"> 547 + <div class="account-switcher-header">Saved accounts</div> 548 + <div class="account-list"> 549 + <div class="account-item" onclick="switchAccount('jay.bsky.social')"> 550 + <div class="account-avatar">J</div> 551 + <div class="account-info"> 552 + <div class="account-display-name">Jay</div> 553 + <div class="account-handle">jay.bsky.social</div> 554 + </div> 555 + <button class="account-remove" title="Remove account" onclick="removeAccount(event, 'jay.bsky.social')"> 556 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 557 + <line x1="18" y1="6" x2="6" y2="18"/> 558 + <line x1="6" y1="6" x2="18" y2="18"/> 559 + </svg> 560 + </button> 561 + </div> 562 + <div class="account-item" onclick="switchAccount('rose.bsky.social')"> 563 + <div class="account-avatar" style="background: linear-gradient(135deg, #e879f9, #a21caf);">R</div> 564 + <div class="account-info"> 565 + <div class="account-display-name">Rose</div> 566 + <div class="account-handle">rose.bsky.social</div> 567 + </div> 568 + <button class="account-remove" title="Remove account" onclick="removeAccount(event, 'rose.bsky.social')"> 569 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 570 + <line x1="18" y1="6" x2="6" y2="18"/> 571 + <line x1="6" y1="6" x2="18" y2="18"/> 572 + </svg> 573 + </button> 574 + </div> 182 575 </div> 576 + </div> 577 + 578 + <div class="divider-with-text">Debug</div> 183 579 184 - <div class="input-group"> 185 - <label class="input-label">App Password</label> 186 - <input type="password" class="input" placeholder="xxxx-xxxx-xxxx-xxxx"> 580 + <div class="debug-section"> 581 + <div class="debug-header"> 582 + <span class="debug-icon"> 583 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 584 + <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/> 585 + </svg> 586 + </span> 587 + <span class="debug-title">App Password Login</span> 588 + <button class="debug-toggle" id="debug-toggle" onclick="toggleDebug()">Show</button> 589 + </div> 590 + <div class="debug-body hidden" id="debug-body"> 591 + <div class="input-group" style="margin-bottom:0; margin-top:0;"> 592 + <div class="input-with-icon"> 593 + <svg class="input-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 594 + <rect x="3" y="11" width="18" height="11" rx="2" ry="2"/> 595 + <path d="M7 11V7a5 5 0 0 1 10 0v4"/> 596 + </svg> 597 + <input type="password" class="input" placeholder="xxxx-xxxx-xxxx-xxxx"> 598 + </div> 599 + </div> 600 + <button class="btn btn-secondary" style="width:100%; display:flex; align-items:center; justify-content:center; gap:8px;"> 601 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 602 + <path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/> 603 + <polyline points="10 17 15 12 10 7"/> 604 + <line x1="15" y1="12" x2="3" y2="12"/> 605 + </svg> 606 + Sign In 607 + </button> 608 + <p class="help-text"> 609 + Can be generated via BlueSky's App Passwords section at bsky.app. 610 + </p> 187 611 </div> 612 + </div> 188 613 189 - <button type="submit" class="btn btn-primary"> 190 - Sign In 191 - </button> 192 - </form> 614 + <div class="login-footer"> 615 + <button class="footer-btn">Terms of Service</button> 616 + <span class="footer-dot">•</span> 617 + <button class="footer-btn">Privacy Policy</button> 618 + </div> 193 619 194 - <p class="help-text"> 195 - App passwords can be generated in BlueSky Settings → App Passwords 196 - </p> 197 620 </div> 198 - </div> 199 - 200 - <!-- Footer --> 201 - <div class="login-footer"> 202 - <p class="terms-text"> 203 - By continuing, you agree to BlueSky's<br> 204 - <a href="#" class="link">Terms of Service</a> and <a href="#" class="link">Privacy Policy</a> 205 - </p> 206 621 </div> 207 622 </div> 208 623 </div> 624 + 625 + <script> 626 + function selectProvider(btn, provider) { 627 + document.querySelectorAll('.segment').forEach(s => s.classList.remove('active')); 628 + btn.classList.add('active'); 629 + } 630 + 631 + let debounceTimer; 632 + function onHandleInput(input) { 633 + clearTimeout(debounceTimer); 634 + debounceTimer = setTimeout(() => { 635 + const dropdown = document.getElementById('typeahead-dropdown'); 636 + dropdown.style.display = input.value.length >= 2 ? 'block' : 'none'; 637 + }, 300); 638 + } 639 + 640 + function selectHandle(handle) { 641 + document.getElementById('handle-input').value = handle; 642 + document.getElementById('typeahead-dropdown').style.display = 'none'; 643 + } 644 + 645 + function toggleDebug() { 646 + const body = document.getElementById('debug-body'); 647 + const toggle = document.getElementById('debug-toggle'); 648 + const hidden = body.classList.toggle('hidden'); 649 + toggle.textContent = hidden ? 'Show' : 'Hide'; 650 + } 651 + 652 + function switchAccount(handle) { 653 + document.getElementById('handle-input').value = handle; 654 + } 655 + 656 + function removeAccount(event, handle) { 657 + event.stopPropagation(); 658 + const item = event.currentTarget.closest('.account-item'); 659 + item.remove(); 660 + const list = document.querySelector('.account-list'); 661 + if (!list.children.length) { 662 + document.querySelector('.account-switcher').remove(); 663 + } 664 + } 665 + 666 + // Close typeahead on outside click 667 + document.addEventListener('click', (e) => { 668 + if (!e.target.closest('.typeahead-wrapper')) { 669 + document.getElementById('typeahead-dropdown').style.display = 'none'; 670 + } 671 + }); 672 + </script> 209 673 </body> 210 674 </html>
+13 -1
lib/core/router/app_router.dart
··· 130 130 return null; 131 131 }, 132 132 routes: [ 133 - GoRoute(path: '/login', pageBuilder: (context, state) => _page(context, state, const LoginScreen())), 133 + GoRoute( 134 + path: '/login', 135 + pageBuilder: (context, state) { 136 + final initialHandle = state.uri.queryParameters['handle']?.trim(); 137 + final hasInitialHandle = initialHandle != null && initialHandle.isNotEmpty; 138 + final autoStartOAuth = state.uri.queryParameters['reauth'] == '1' && hasInitialHandle; 139 + return _page( 140 + context, 141 + state, 142 + LoginScreen(initialHandle: hasInitialHandle ? initialHandle : null, autoStartOAuth: autoStartOAuth), 143 + ); 144 + }, 145 + ), 134 146 GoRoute( 135 147 path: '/settings', 136 148 pageBuilder: (context, state) => _page(context, state, const SettingsScreen()),
+10 -10
lib/features/account/presentation/account_switcher_sheet.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:go_router/go_router.dart'; 6 + import 'package:lazurite/core/database/app_database.dart'; 6 7 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 7 8 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 8 9 import 'package:lazurite/features/auth/data/atproto_identifier.dart'; ··· 21 22 return null; 22 23 } 23 24 24 - return switch (validationError.code) { 25 - AtProtoIdentifierValidationErrorCode.empty => 'Enter a Bluesky handle or DID', 26 - AtProtoIdentifierValidationErrorCode.unsupportedDid => 'Use a did:plc:... or did:web:... identifier', 27 - AtProtoIdentifierValidationErrorCode.invalidDid => 'Enter a complete DID like did:plc:... or did:web:...', 28 - AtProtoIdentifierValidationErrorCode.invalidHandle => 'Enter a full handle like username.bsky.social', 29 - }; 25 + final code = validationError.code; 26 + return code.message; 30 27 } 31 28 32 29 void showAccountSwitcherSheet(BuildContext context) { ··· 108 105 ), 109 106 ], 110 107 ), 111 - onTap: isActive ? null : () => _onSwitchAccount(context, account.did), 108 + onTap: isActive ? null : () => _onSwitchAccount(context, account), 112 109 ); 113 110 }, 114 111 ); ··· 141 138 ), 142 139 ); 143 140 144 - Future<void> _onSwitchAccount(BuildContext context, String did) async { 141 + Future<void> _onSwitchAccount(BuildContext context, Account account) async { 145 142 final cubit = context.read<AccountSwitcherCubit>(); 146 143 Navigator.pop(context); 147 - final tokens = await cubit.switchAccount(did); 144 + final tokens = await cubit.switchAccount(account.did); 148 145 if (tokens != null) { 149 146 authBloc.add(SessionRestored(tokens: tokens)); 150 147 return; ··· 154 151 showAppSnackBar(parentContext, 'Please sign in again for that account.'); 155 152 final router = GoRouter.maybeOf(parentContext); 156 153 if (router != null) { 157 - unawaited(Future<void>.delayed(Duration.zero, () => router.go('/login?reauth=1'))); 154 + unawaited(Future<void>.delayed(Duration.zero, () => router.go(_reauthLoginLocation(account.handle)))); 158 155 } 159 156 } 160 157 } 158 + 159 + String _reauthLoginLocation(String handle) => 160 + Uri(path: '/login', queryParameters: {'reauth': '1', 'handle': handle}).toString(); 161 161 162 162 Future<void> _onAddAccount(BuildContext context) async { 163 163 final cubit = context.read<AccountSwitcherCubit>();
+13 -1
lib/features/auth/data/atproto_identifier.dart
··· 1 1 import 'package:flutter/foundation.dart'; 2 2 3 - enum AtProtoIdentifierValidationErrorCode { empty, unsupportedDid, invalidDid, invalidHandle } 3 + enum AtProtoIdentifierValidationErrorCode { 4 + empty, 5 + unsupportedDid, 6 + invalidDid, 7 + invalidHandle; 8 + 9 + String get message => switch (this) { 10 + AtProtoIdentifierValidationErrorCode.empty => 'Enter a Bluesky handle or DID', 11 + AtProtoIdentifierValidationErrorCode.unsupportedDid => 'Use a did:plc:... or did:web:... identifier', 12 + AtProtoIdentifierValidationErrorCode.invalidDid => 'Enter a complete DID like did:plc:... or did:web:...', 13 + AtProtoIdentifierValidationErrorCode.invalidHandle => 'Enter a full handle like username.bsky.social', 14 + }; 15 + } 4 16 5 17 class AtProtoIdentifierValidationError { 6 18 const AtProtoIdentifierValidationError(this.code);
+5 -1
lib/features/auth/data/auth_repository.dart
··· 463 463 await atp.deleteSession(refreshJwt: storedSession!.refreshToken!, service: storedSession.service); 464 464 } 465 465 } finally { 466 - await clearSession(); 466 + if (storedSession != null) { 467 + await _invalidateSession(storedSession); 468 + } else { 469 + await _database.deleteSetting(AppDatabase.activeAccountDidSettingKey); 470 + } 467 471 log.i('AuthRepository: Logout complete'); 468 472 } 469 473 }
+397 -43
lib/features/auth/presentation/login_screen.dart
··· 1 1 import 'dart:async'; 2 + import 'dart:convert'; 2 3 3 4 import 'package:flutter/foundation.dart'; 4 5 import 'package:flutter/material.dart'; 5 6 import 'package:flutter_bloc/flutter_bloc.dart'; 6 7 import 'package:flutter_svg/flutter_svg.dart'; 7 8 import 'package:go_router/go_router.dart'; 9 + import 'package:lazurite/core/database/app_database.dart'; 8 10 import 'package:lazurite/core/network/app_view_provider.dart'; 11 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 9 12 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 + import 'package:lazurite/features/auth/data/atproto_identifier.dart'; 10 14 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 11 15 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 12 16 import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; 13 17 import 'package:lazurite/features/typeahead/data/typeahead_result.dart'; 14 18 import 'package:lazurite/features/typeahead/presentation/typeahead_text_field.dart'; 19 + import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; 15 20 16 21 class LoginScreen extends StatefulWidget { 17 - const LoginScreen({this.typeaheadRepository, super.key}); 22 + const LoginScreen({this.initialHandle, this.autoStartOAuth = false, this.typeaheadRepository, super.key}); 18 23 24 + final String? initialHandle; 25 + final bool autoStartOAuth; 19 26 final TypeaheadRepository? typeaheadRepository; 20 27 21 28 @override ··· 26 33 final _handleController = TextEditingController(); 27 34 final _appPasswordController = TextEditingController(); 28 35 final _formKey = GlobalKey<FormState>(); 36 + final Map<String, Future<String?>> _avatarFutureByDid = <String, Future<String?>>{}; 29 37 bool _showDebugForm = false; 30 38 bool _isPersistingProvider = false; 39 + bool _didRequestAccountsLoad = false; 40 + bool _didRequestAutoOAuth = false; 41 + bool _didLogMissingAccountSwitcherProvider = false; 42 + bool _didLogAvatarLookupFailure = false; 31 43 late final TypeaheadRepository _typeaheadRepository; 32 44 45 + AccountSwitcherCubit? _maybeAccountSwitcherCubit(BuildContext context) { 46 + try { 47 + return context.read<AccountSwitcherCubit>(); 48 + } catch (_) { 49 + if (kDebugMode && !_didLogMissingAccountSwitcherProvider) { 50 + debugPrint('LoginScreen: AccountSwitcherCubit unavailable for login route.'); 51 + _didLogMissingAccountSwitcherProvider = true; 52 + } 53 + return null; 54 + } 55 + } 56 + 33 57 @override 34 58 void initState() { 35 59 super.initState(); 36 60 _typeaheadRepository = 37 61 widget.typeaheadRepository ?? TypeaheadRepository(provider: TypeaheadRepository.communityProvider); 62 + final initialHandle = widget.initialHandle?.trim(); 63 + if (initialHandle != null && initialHandle.isNotEmpty) { 64 + _handleController.text = initialHandle; 65 + _handleController.selection = TextSelection.collapsed(offset: initialHandle.length); 66 + } 67 + } 68 + 69 + @override 70 + void didChangeDependencies() { 71 + super.didChangeDependencies(); 72 + _requestAccountsLoadIfAvailable(); 73 + _requestAutoOAuthIfNeeded(); 74 + } 75 + 76 + void _requestAccountsLoadIfAvailable() { 77 + if (_didRequestAccountsLoad) { 78 + return; 79 + } 80 + 81 + final accountSwitcherCubit = _maybeAccountSwitcherCubit(context); 82 + if (accountSwitcherCubit == null) { 83 + return; 84 + } 85 + 86 + _didRequestAccountsLoad = true; 87 + WidgetsBinding.instance.addPostFrameCallback((_) { 88 + if (!mounted) { 89 + return; 90 + } 91 + unawaited(accountSwitcherCubit.loadAccounts()); 92 + }); 93 + } 94 + 95 + void _requestAutoOAuthIfNeeded() { 96 + if (_didRequestAutoOAuth || !widget.autoStartOAuth || _handleController.text.trim().isEmpty) { 97 + return; 98 + } 99 + 100 + _didRequestAutoOAuth = true; 101 + WidgetsBinding.instance.addPostFrameCallback((_) { 102 + if (!mounted) { 103 + return; 104 + } 105 + unawaited(_onOAuthLogin()); 106 + }); 38 107 } 39 108 40 109 @override ··· 49 118 return; 50 119 } 51 120 121 + final handle = _normalizedIdentifierInput; 52 122 final persisted = await _persistSelectedProvider(); 53 123 if (!persisted) { 54 124 return; ··· 56 126 if (!mounted) { 57 127 return; 58 128 } 59 - context.read<AuthBloc>().add(OAuthLoginRequested(handle: _handleController.text.trim())); 129 + context.read<AuthBloc>().add(OAuthLoginRequested(handle: handle)); 60 130 } 61 131 62 132 Future<void> _onAppPasswordLogin() async { ··· 64 134 return; 65 135 } 66 136 137 + final handle = _normalizedIdentifierInput; 67 138 final persisted = await _persistSelectedProvider(); 68 139 if (!persisted) { 69 140 return; ··· 71 142 if (!mounted) { 72 143 return; 73 144 } 74 - context.read<AuthBloc>().add( 75 - LoginRequested(handle: _handleController.text.trim(), appPassword: _appPasswordController.text.trim()), 76 - ); 145 + context.read<AuthBloc>().add(LoginRequested(handle: handle, appPassword: _appPasswordController.text.trim())); 77 146 } 78 147 79 148 bool _isHandleValid() => _formKey.currentState?.validate() ?? false; 80 149 150 + String get _normalizedIdentifierInput => normalizeAtProtoIdentifierForAuth(_handleController.text); 151 + 152 + String? _validateIdentifierInput(String? value) { 153 + final normalized = normalizeAtProtoIdentifierForAuth(value ?? ''); 154 + final validationError = validateAtProtoIdentifierForAuth(normalized); 155 + return validationError?.code.message; 156 + } 157 + 81 158 void _onTypeaheadSelected(TypeaheadResult result) { 82 159 _handleController.text = result.handle; 83 160 unawaited(_onOAuthLogin()); 84 161 } 85 162 163 + void _fillHandleFromAccount(Account account) { 164 + _handleController.text = account.handle; 165 + _handleController.selection = TextSelection.collapsed(offset: _handleController.text.length); 166 + } 167 + 168 + Future<void> _onSelectSavedAccount(Account account) async { 169 + _fillHandleFromAccount(account); 170 + 171 + final cubit = _maybeAccountSwitcherCubit(context); 172 + if (cubit == null) { 173 + return; 174 + } 175 + 176 + final tokens = await cubit.switchAccount(account.did); 177 + if (!mounted) { 178 + return; 179 + } 180 + 181 + if (tokens != null) { 182 + context.read<AuthBloc>().add(SessionRestored(tokens: tokens)); 183 + return; 184 + } 185 + 186 + await _onOAuthLogin(); 187 + } 188 + 86 189 Future<bool> _persistSelectedProvider() async { 87 190 final settingsCubit = context.read<SettingsCubit>(); 88 191 if (_isPersistingProvider) { ··· 111 214 } 112 215 } 113 216 217 + Future<void> _onRemoveSavedAccount(Account account) async { 218 + final cubit = _maybeAccountSwitcherCubit(context); 219 + if (cubit == null) { 220 + return; 221 + } 222 + 223 + final remove = await showDialog<bool>( 224 + context: context, 225 + builder: (dialogContext) => AlertDialog( 226 + title: const Text('Remove Account'), 227 + content: Text('Remove @${account.handle} from this device?'), 228 + actions: [ 229 + TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), 230 + FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Remove')), 231 + ], 232 + ), 233 + ); 234 + 235 + if (remove != true) { 236 + return; 237 + } 238 + 239 + final result = await cubit.removeAccount(account.did); 240 + if (!result.removed) { 241 + if (!mounted) { 242 + return; 243 + } 244 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Unable to remove account right now.'))); 245 + return; 246 + } 247 + 248 + final switchedTokens = result.switchedTokens; 249 + if (switchedTokens != null && mounted) { 250 + context.read<AuthBloc>().add(SessionRestored(tokens: switchedTokens)); 251 + } 252 + 253 + if (result.requiresSignIn && mounted) { 254 + context.read<AuthBloc>().add(const SessionCleared()); 255 + final router = GoRouter.maybeOf(context); 256 + if (router != null) { 257 + router.go('/login?reauth=1'); 258 + } 259 + } 260 + } 261 + 262 + Future<String?> _loadCachedAvatarUrlForDid(String did) async { 263 + try { 264 + final database = context.read<AppDatabase>(); 265 + final profile = await (database.select( 266 + database.cachedProfiles, 267 + )..where((profile) => profile.did.equals(did))).getSingleOrNull(); 268 + if (profile == null) { 269 + return null; 270 + } 271 + 272 + final json = jsonDecode(profile.payload); 273 + if (json is! Map<String, dynamic>) { 274 + return null; 275 + } 276 + 277 + final avatar = json['avatar']; 278 + if (avatar is String && avatar.isNotEmpty) { 279 + return avatar; 280 + } 281 + return null; 282 + } catch (_) { 283 + if (kDebugMode && !_didLogAvatarLookupFailure) { 284 + debugPrint('LoginScreen: cached avatar lookup unavailable.'); 285 + _didLogAvatarLookupFailure = true; 286 + } 287 + return null; 288 + } 289 + } 290 + 291 + Future<String?> _avatarFutureForDid(String did) => 292 + _avatarFutureByDid.putIfAbsent(did, () => _loadCachedAvatarUrlForDid(did)); 293 + 294 + void _pruneAvatarFutureCache(Iterable<String> activeDids) { 295 + final activeDidSet = activeDids.toSet(); 296 + _avatarFutureByDid.removeWhere((did, _) => !activeDidSet.contains(did)); 297 + } 298 + 114 299 @override 115 300 Widget build(BuildContext context) { 116 301 final theme = Theme.of(context); 117 302 final colorScheme = theme.colorScheme; 303 + final accountSwitcherCubit = _maybeAccountSwitcherCubit(context); 118 304 119 305 return Scaffold( 120 306 appBar: AppBar( ··· 206 392 ); 207 393 }, 208 394 ), 209 - TypeaheadTextField( 210 - controller: _handleController, 211 - repository: _typeaheadRepository, 212 - onSelected: _onTypeaheadSelected, 213 - minChars: 2, 214 - debounceMs: 300, 215 - limit: 8, 216 - decoration: const InputDecoration( 217 - labelText: 'Handle or DID', 218 - hintText: 'username.bsky.social or did:plc:...', 219 - prefixIcon: Icon(Icons.person_outline), 220 - border: OutlineInputBorder(), 221 - ), 222 - autocorrect: false, 223 - textInputAction: TextInputAction.next, 224 - validator: (value) { 225 - if (value == null || value.trim().isEmpty) { 226 - return 'Enter your BlueSky handle or DID'; 227 - } 228 - return null; 229 - }, 230 - ), 231 - const SizedBox(height: 20), 232 395 BlocBuilder<AuthBloc, AuthState>( 233 396 builder: (context, state) { 234 397 final busy = state.isLoading || _isPersistingProvider; 235 - return FilledButton.icon( 236 - onPressed: busy 237 - ? null 238 - : () { 239 - unawaited(_onOAuthLogin()); 240 - }, 241 - icon: busy 242 - ? const SizedBox( 243 - width: 18, 244 - height: 18, 245 - child: CircularProgressIndicator(strokeWidth: 2), 246 - ) 247 - : const Icon(Icons.language), 248 - label: Text(busy ? 'Starting sign in...' : 'Continue'), 249 - style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 18)), 398 + final border = OutlineInputBorder( 399 + borderRadius: BorderRadius.circular(14), 400 + borderSide: BorderSide(color: colorScheme.outlineVariant, width: 1.5), 401 + ); 402 + 403 + return TypeaheadTextField( 404 + controller: _handleController, 405 + repository: _typeaheadRepository, 406 + onSelected: _onTypeaheadSelected, 407 + minChars: 2, 408 + debounceMs: 300, 409 + limit: 8, 410 + decoration: InputDecoration( 411 + labelText: 'Handle or DID', 412 + hintText: 'username.bsky.social or did:plc:...', 413 + prefixIcon: const Icon(Icons.person_outline), 414 + filled: true, 415 + fillColor: colorScheme.surface, 416 + contentPadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), 417 + border: border, 418 + enabledBorder: border, 419 + focusedBorder: border.copyWith( 420 + borderSide: BorderSide(color: colorScheme.primary, width: 1.5), 421 + ), 422 + suffixIconConstraints: const BoxConstraints(minWidth: 52, minHeight: 52), 423 + suffixIcon: Padding( 424 + padding: const EdgeInsets.all(5), 425 + child: Tooltip( 426 + message: busy ? 'Starting sign in' : 'Continue', 427 + child: Semantics( 428 + label: busy ? 'Starting sign in' : 'Continue sign in', 429 + button: true, 430 + enabled: !busy, 431 + child: FilledButton( 432 + key: const ValueKey<String>('login-continue-button'), 433 + onPressed: busy 434 + ? null 435 + : () { 436 + unawaited(_onOAuthLogin()); 437 + }, 438 + style: FilledButton.styleFrom( 439 + padding: EdgeInsets.zero, 440 + minimumSize: const Size(40, 40), 441 + maximumSize: const Size(40, 40), 442 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), 443 + ), 444 + child: busy 445 + ? const SizedBox( 446 + width: 18, 447 + height: 18, 448 + child: CircularProgressIndicator(strokeWidth: 2), 449 + ) 450 + : const Icon(Icons.arrow_forward_rounded, size: 18), 451 + ), 452 + ), 453 + ), 454 + ), 455 + ), 456 + autocorrect: false, 457 + textInputAction: TextInputAction.go, 458 + onFieldSubmitted: (_) => unawaited(_onOAuthLogin()), 459 + validator: _validateIdentifierInput, 250 460 ); 251 461 }, 252 462 ), 463 + const SizedBox(height: 16), 464 + if (accountSwitcherCubit != null) 465 + BlocProvider.value( 466 + value: accountSwitcherCubit, 467 + child: BlocBuilder<AccountSwitcherCubit, AccountSwitcherState>( 468 + builder: (context, state) { 469 + if (state.status == AccountSwitcherStatus.loading || 470 + state.status == AccountSwitcherStatus.initial) { 471 + return const _SavedAccountsLoading(); 472 + } 473 + 474 + if (state.accounts.isEmpty) { 475 + return const SizedBox.shrink(); 476 + } 477 + _pruneAvatarFutureCache(state.accounts.map((account) => account.did)); 478 + return _SavedAccountsSection( 479 + accounts: state.accounts, 480 + avatarFutureForDid: _avatarFutureForDid, 481 + onSelect: (account) { 482 + unawaited(_onSelectSavedAccount(account)); 483 + }, 484 + onRemove: (account) { 485 + unawaited(_onRemoveSavedAccount(account)); 486 + }, 487 + ); 488 + }, 489 + ), 490 + ), 253 491 if (kDebugMode) ...[ 254 492 const SizedBox(height: 24), 255 493 Row( ··· 378 616 Text(name), 379 617 ], 380 618 ); 619 + } 620 + 621 + class _SavedAccountsSection extends StatelessWidget { 622 + const _SavedAccountsSection({ 623 + required this.accounts, 624 + required this.avatarFutureForDid, 625 + required this.onSelect, 626 + required this.onRemove, 627 + }); 628 + 629 + final List<Account> accounts; 630 + final Future<String?> Function(String did) avatarFutureForDid; 631 + final ValueChanged<Account> onSelect; 632 + final ValueChanged<Account> onRemove; 633 + 634 + @override 635 + Widget build(BuildContext context) { 636 + final theme = Theme.of(context); 637 + final colorScheme = theme.colorScheme; 638 + return Column( 639 + crossAxisAlignment: CrossAxisAlignment.stretch, 640 + children: [ 641 + Text('Saved accounts', style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600)), 642 + const SizedBox(height: 8), 643 + DecoratedBox( 644 + decoration: BoxDecoration( 645 + color: colorScheme.surfaceContainerLowest, 646 + borderRadius: BorderRadius.circular(14), 647 + border: Border.all(color: colorScheme.outlineVariant), 648 + ), 649 + child: Column( 650 + children: [ 651 + for (var index = 0; index < accounts.length; index++) ...[ 652 + _SavedAccountTile( 653 + key: ValueKey<String>('saved-account-${accounts[index].did}'), 654 + account: accounts[index], 655 + avatarFutureForDid: avatarFutureForDid, 656 + onTap: () => onSelect(accounts[index]), 657 + onRemove: () => onRemove(accounts[index]), 658 + ), 659 + if (index != accounts.length - 1) Divider(height: 1, color: colorScheme.outlineVariant), 660 + ], 661 + ], 662 + ), 663 + ), 664 + ], 665 + ); 666 + } 667 + } 668 + 669 + class _SavedAccountsLoading extends StatelessWidget { 670 + const _SavedAccountsLoading(); 671 + 672 + @override 673 + Widget build(BuildContext context) { 674 + final theme = Theme.of(context); 675 + final colorScheme = theme.colorScheme; 676 + return Column( 677 + crossAxisAlignment: CrossAxisAlignment.stretch, 678 + children: [ 679 + Text('Saved accounts', style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600)), 680 + const SizedBox(height: 8), 681 + DecoratedBox( 682 + decoration: BoxDecoration( 683 + color: colorScheme.surfaceContainerLowest, 684 + borderRadius: BorderRadius.circular(14), 685 + border: Border.all(color: colorScheme.outlineVariant), 686 + ), 687 + child: const Padding( 688 + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), 689 + child: Row( 690 + children: [ 691 + SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)), 692 + SizedBox(width: 10), 693 + Text('Loading saved accounts...'), 694 + ], 695 + ), 696 + ), 697 + ), 698 + ], 699 + ); 700 + } 701 + } 702 + 703 + class _SavedAccountTile extends StatelessWidget { 704 + const _SavedAccountTile({ 705 + required this.account, 706 + required this.avatarFutureForDid, 707 + required this.onTap, 708 + required this.onRemove, 709 + super.key, 710 + }); 711 + 712 + final Account account; 713 + final Future<String?> Function(String did) avatarFutureForDid; 714 + final VoidCallback onTap; 715 + final VoidCallback onRemove; 716 + 717 + @override 718 + Widget build(BuildContext context) { 719 + final label = account.displayName ?? account.handle; 720 + return FutureBuilder<String?>( 721 + future: avatarFutureForDid(account.did), 722 + builder: (context, snapshot) { 723 + return ListTile( 724 + dense: true, 725 + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), 726 + leading: ProfileAvatar(size: 36, fallbackText: label, imageUrl: snapshot.data), 727 + title: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), 728 + subtitle: Text('@${account.handle}', maxLines: 1, overflow: TextOverflow.ellipsis), 729 + trailing: IconButton(tooltip: 'Remove account', icon: const Icon(Icons.close_rounded), onPressed: onRemove), 730 + onTap: onTap, 731 + ); 732 + }, 733 + ); 734 + } 381 735 } 382 736 383 737 class _LogoCard extends StatelessWidget {
+6 -3
lib/features/typeahead/presentation/typeahead_text_field.dart
··· 45 45 } 46 46 47 47 class _TypeaheadTextFieldState extends State<TypeaheadTextField> with WidgetsBindingObserver { 48 + static const double _overlayGap = 6; 49 + 48 50 final LayerLink _layerLink = LayerLink(); 49 51 final GlobalKey _fieldKey = GlobalKey(); 50 52 late FocusNode _focusNode; ··· 230 232 child: CompositedTransformFollower( 231 233 link: _layerLink, 232 234 showWhenUnlinked: false, 233 - offset: Offset(0, fieldSize.height + 4), 235 + offset: Offset(0, fieldSize.height + _overlayGap), 234 236 child: Align( 235 237 alignment: Alignment.topLeft, 236 238 child: TapRegion( ··· 259 261 260 262 @override 261 263 Widget build(BuildContext context) { 262 - final resolvedDecoration = _isLoading 263 - ? (widget.decoration ?? const InputDecoration()).copyWith( 264 + final baseDecoration = widget.decoration ?? const InputDecoration(); 265 + final resolvedDecoration = _isLoading && baseDecoration.suffixIcon == null 266 + ? baseDecoration.copyWith( 264 267 suffixIcon: const Padding( 265 268 padding: EdgeInsets.all(12), 266 269 child: SizedBox(
+20 -3
test/core/router/app_router_test.dart
··· 122 122 useSystemTheme: false, 123 123 ), 124 124 ); 125 + when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); 125 126 when(() => connectivityCubit.state).thenReturn(const ConnectivityState.online()); 126 127 when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 127 128 when(() => accountSwitcherCubit.loadAccounts()).thenAnswer((_) async {}); ··· 532 533 await tester.tap(find.byTooltip('Back')); 533 534 await tester.pumpAndSettle(); 534 535 535 - expect(find.text('Continue'), findsOneWidget); 536 + expect(find.byKey(const ValueKey<String>('login-continue-button')), findsOneWidget); 536 537 537 538 router.dispose(); 538 539 }); ··· 581 582 router.go('/settings/video-limits'); 582 583 await tester.pumpAndSettle(); 583 584 584 - expect(find.text('Continue'), findsOneWidget); 585 + expect(find.byKey(const ValueKey<String>('login-continue-button')), findsOneWidget); 585 586 586 587 router.dispose(); 587 588 }); ··· 595 596 router.go('/login?reauth=1'); 596 597 await tester.pumpAndSettle(); 597 598 598 - expect(find.text('Continue'), findsOneWidget); 599 + expect(find.byKey(const ValueKey<String>('login-continue-button')), findsOneWidget); 600 + 601 + router.dispose(); 602 + }); 603 + 604 + testWidgets('reauth login route passes handle through and starts OAuth for that account', (tester) async { 605 + final router = AppRouter(authBloc: authBloc).router; 606 + 607 + await tester.pumpWidget(buildSubjectWithRouter(router)); 608 + await tester.pumpAndSettle(); 609 + 610 + router.go('/login?reauth=1&handle=alice.bsky.social'); 611 + await tester.pumpAndSettle(); 612 + 613 + final field = tester.widget<TextFormField>(find.byType(TextFormField).first); 614 + expect(field.controller?.text, 'alice.bsky.social'); 615 + verify(() => authBloc.add(const OAuthLoginRequested(handle: 'alice.bsky.social'))).called(1); 599 616 600 617 router.dispose(); 601 618 });
+57
test/features/account/presentation/account_switcher_sheet_test.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:flutter_bloc/flutter_bloc.dart'; 4 4 import 'package:flutter_test/flutter_test.dart'; 5 + import 'package:go_router/go_router.dart'; 5 6 import 'package:lazurite/core/database/app_database.dart'; 6 7 import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 7 8 import 'package:lazurite/features/account/presentation/account_switcher_sheet.dart'; ··· 77 78 ), 78 79 ), 79 80 ), 81 + ), 82 + ); 83 + } 84 + 85 + Widget buildSubjectWithRouter(GoRouter router) { 86 + return MultiBlocProvider( 87 + providers: [ 88 + BlocProvider<AuthBloc>.value(value: authBloc), 89 + BlocProvider<AccountSwitcherCubit>.value(value: cubit), 90 + ], 91 + child: RepositoryProvider<TypeaheadRepository>.value( 92 + value: typeaheadRepository, 93 + child: MaterialApp.router(routerConfig: router), 80 94 ), 81 95 ); 82 96 } ··· 205 219 206 220 verifyNever(() => authBloc.add(any(that: isA<LogoutRequested>()))); 207 221 verify(() => cubit.switchAccount('did:plc:user2')).called(1); 222 + }); 223 + 224 + testWidgets('reauth fallback routes to login with selected account handle', (tester) async { 225 + late GoRouter router; 226 + router = GoRouter( 227 + routes: [ 228 + GoRoute( 229 + path: '/', 230 + builder: (context, state) => Scaffold( 231 + body: TextButton(onPressed: () => showAccountSwitcherSheet(context), child: const Text('Open')), 232 + ), 233 + ), 234 + GoRoute( 235 + path: '/login', 236 + builder: (context, state) => Scaffold(body: Text('reauth:${state.uri.queryParameters['handle'] ?? ''}')), 237 + ), 238 + ], 239 + ); 240 + addTearDown(router.dispose); 241 + 242 + when(() => cubit.state).thenReturn( 243 + AccountSwitcherState.ready( 244 + accounts: [ 245 + makeAccount(did: 'did:plc:user1', handle: 'alice.bsky.social'), 246 + makeAccount(did: 'did:plc:user2', handle: 'bob.bsky.social'), 247 + ], 248 + activeDid: 'did:plc:user1', 249 + ), 250 + ); 251 + when(() => cubit.switchAccount('did:plc:user2')).thenAnswer((_) async => null); 252 + 253 + await tester.pumpWidget(buildSubjectWithRouter(router)); 254 + await tester.tap(find.text('Open')); 255 + await tester.pump(); 256 + await tester.pump(const Duration(milliseconds: 500)); 257 + 258 + await tester.tap(find.text('bob.bsky.social')); 259 + await tester.pumpAndSettle(); 260 + 261 + expect(router.routeInformationProvider.value.uri.path, '/login'); 262 + expect(router.routeInformationProvider.value.uri.queryParameters['reauth'], '1'); 263 + expect(router.routeInformationProvider.value.uri.queryParameters['handle'], 'bob.bsky.social'); 264 + expect(find.text('reauth:bob.bsky.social'), findsOneWidget); 208 265 }); 209 266 210 267 testWidgets('tapping active account does nothing', (tester) async {
+35 -3
test/features/auth/data/auth_repository_test.dart
··· 724 724 }); 725 725 726 726 group('logout', () { 727 - test('should clear session', () async { 727 + test('removes only the active account session', () async { 728 + final account = Account( 729 + did: 'did:plc:active', 730 + handle: 'active.bsky.social', 731 + service: null, 732 + oauthService: null, 733 + accessToken: 'access_token', 734 + refreshToken: null, 735 + dpopPublicKey: null, 736 + dpopPrivateKey: null, 737 + dpopNonce: null, 738 + displayName: 'Active User', 739 + expiresAt: null, 740 + createdAt: DateTime.now(), 741 + updatedAt: DateTime.now(), 742 + ); 743 + 744 + when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => account); 745 + when(() => mockDatabase.deleteAccount(account.did)).thenAnswer((_) async => 1); 746 + when( 747 + () => mockDatabase.getSetting(AppDatabase.activeAccountDidSettingKey), 748 + ).thenAnswer((_) async => account.did); 749 + when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); 750 + 751 + await authRepository.logout(); 752 + 753 + verify(() => mockDatabase.getActiveAccount()).called(1); 754 + verify(() => mockDatabase.deleteAccount(account.did)).called(1); 755 + verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 756 + verifyNever(() => mockDatabase.deleteAllAccounts()); 757 + }); 758 + 759 + test('clears stale active account setting when no active account exists', () async { 728 760 when(() => mockDatabase.getActiveAccount()).thenAnswer((_) async => null); 729 - when(() => mockDatabase.deleteAllAccounts()).thenAnswer((_) async => 1); 730 761 when(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).thenAnswer((_) async => 1); 731 762 732 763 await authRepository.logout(); 733 764 734 765 verify(() => mockDatabase.getActiveAccount()).called(1); 735 - verify(() => mockDatabase.deleteAllAccounts()).called(1); 736 766 verify(() => mockDatabase.deleteSetting(AppDatabase.activeAccountDidSettingKey)).called(1); 767 + verifyNever(() => mockDatabase.deleteAllAccounts()); 768 + verifyNever(() => mockDatabase.deleteAccount(any())); 737 769 }); 738 770 }); 739 771
+210 -7
test/features/auth/presentation/login_screen_test.dart
··· 1 + import 'dart:async'; 2 + 1 3 import 'package:bloc_test/bloc_test.dart'; 2 4 import 'package:flutter/material.dart'; 3 5 import 'package:flutter_bloc/flutter_bloc.dart'; 4 6 import 'package:flutter_svg/flutter_svg.dart'; 5 7 import 'package:flutter_test/flutter_test.dart'; 6 8 import 'package:go_router/go_router.dart'; 9 + import 'package:lazurite/core/database/app_database.dart'; 10 + import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; 7 11 import 'package:lazurite/core/theme/app_theme.dart'; 8 12 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 13 + import 'package:lazurite/features/auth/data/models/auth_models.dart'; 9 14 import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; 10 15 import 'package:lazurite/features/settings/bloc/settings_state.dart'; 11 16 import 'package:lazurite/features/auth/presentation/login_screen.dart'; ··· 16 21 class MockAuthBloc extends MockBloc<AuthEvent, AuthState> implements AuthBloc {} 17 22 18 23 class MockSettingsCubit extends MockCubit<SettingsState> implements SettingsCubit {} 24 + 25 + class MockAccountSwitcherCubit extends MockCubit<AccountSwitcherState> implements AccountSwitcherCubit {} 19 26 20 27 void main() { 21 28 late MockAuthBloc authBloc; 22 29 late MockSettingsCubit settingsCubit; 30 + late MockAccountSwitcherCubit accountSwitcherCubit; 23 31 24 32 setUp(() { 25 33 authBloc = MockAuthBloc(); 26 34 settingsCubit = MockSettingsCubit(); 35 + accountSwitcherCubit = MockAccountSwitcherCubit(); 27 36 when(() => authBloc.state).thenReturn(const AuthState.unauthenticated()); 28 37 whenListen(authBloc, const Stream<AuthState>.empty(), initialState: const AuthState.unauthenticated()); 29 38 const settingsState = SettingsState( ··· 34 43 when(() => settingsCubit.state).thenReturn(settingsState); 35 44 whenListen(settingsCubit, const Stream<SettingsState>.empty(), initialState: settingsState); 36 45 when(() => settingsCubit.setAppViewProvider(any())).thenAnswer((_) async {}); 46 + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.ready(accounts: [])); 47 + whenListen( 48 + accountSwitcherCubit, 49 + const Stream<AccountSwitcherState>.empty(), 50 + initialState: const AccountSwitcherState.ready(accounts: []), 51 + ); 52 + when(() => accountSwitcherCubit.loadAccounts()).thenAnswer((_) async {}); 37 53 }); 38 54 39 - Widget buildSubject({ThemeMode themeMode = ThemeMode.system}) { 55 + Widget buildSubject({ 56 + ThemeMode themeMode = ThemeMode.system, 57 + MockAccountSwitcherCubit? accountCubit, 58 + String? initialHandle, 59 + bool autoStartOAuth = false, 60 + }) { 40 61 final typeaheadRepository = _FakeTypeaheadRepository( 41 62 searchHandler: ({required String query, int limit = 10}) async => const [], 42 63 ); ··· 49 70 providers: [ 50 71 BlocProvider<AuthBloc>.value(value: authBloc), 51 72 BlocProvider<SettingsCubit>.value(value: settingsCubit), 73 + BlocProvider<AccountSwitcherCubit>.value(value: accountCubit ?? accountSwitcherCubit), 52 74 ], 53 - child: LoginScreen(typeaheadRepository: typeaheadRepository), 75 + child: LoginScreen( 76 + initialHandle: initialHandle, 77 + autoStartOAuth: autoStartOAuth, 78 + typeaheadRepository: typeaheadRepository, 79 + ), 54 80 ), 55 81 ), 56 82 GoRoute( ··· 79 105 80 106 testWidgets('shows settings icon plus terms and privacy links', (tester) async { 81 107 await tester.pumpWidget(buildSubject()); 82 - await tester.pumpAndSettle(); 108 + await tester.pump(); 109 + await tester.pump(const Duration(milliseconds: 200)); 83 110 84 111 final scrollable = find.byType(Scrollable).first; 85 112 await tester.scrollUntilVisible(find.text('Terms of Service'), 200, scrollable: scrollable); ··· 94 121 95 122 testWidgets('tapping settings icon opens public settings route', (tester) async { 96 123 await tester.pumpWidget(buildSubject()); 97 - await tester.pumpAndSettle(); 124 + await tester.pump(); 125 + await tester.pump(const Duration(milliseconds: 200)); 98 126 99 127 await tester.tap(find.byTooltip('Settings')); 100 128 await tester.pumpAndSettle(); ··· 104 132 105 133 testWidgets('tapping Terms of Service opens terms route', (tester) async { 106 134 await tester.pumpWidget(buildSubject()); 107 - await tester.pumpAndSettle(); 135 + await tester.pump(); 136 + await tester.pump(const Duration(milliseconds: 200)); 108 137 109 138 await tester.scrollUntilVisible(find.text('Terms of Service'), 200, scrollable: find.byType(Scrollable).first); 110 139 await tester.pumpAndSettle(); ··· 162 191 await tester.pumpAndSettle(); 163 192 164 193 expect(find.text('River Tam'), findsOneWidget); 165 - await tester.tap(find.text('River Tam')); 194 + final suggestionTile = tester.widget<ListTile>( 195 + find.byKey(const ValueKey<String>('typeahead-result-did:plc:river')), 196 + ); 197 + suggestionTile.onTap?.call(); 166 198 await tester.pumpAndSettle(); 167 199 168 200 verify(() => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social'))).called(1); ··· 173 205 await tester.pumpAndSettle(); 174 206 175 207 await tester.enterText(find.byType(TextFormField).first, 'river.bsky.social'); 176 - await tester.tap(find.text('Continue')); 208 + await tester.tap(find.byKey(const ValueKey<String>('login-continue-button'))); 209 + await tester.pumpAndSettle(); 210 + 211 + verifyInOrder([ 212 + () => settingsCubit.setAppViewProvider('bluesky'), 213 + () => authBloc.add(const OAuthLoginRequested(handle: 'river.bsky.social')), 214 + ]); 215 + }); 216 + 217 + testWidgets('handle field exposes persistent label and continue tooltip', (tester) async { 218 + await tester.pumpWidget(buildSubject()); 219 + await tester.pumpAndSettle(); 220 + 221 + final field = tester.widget<TextField>(find.byType(TextField).first); 222 + expect(field.decoration?.labelText, 'Handle or DID'); 223 + expect(find.byTooltip('Continue'), findsOneWidget); 224 + }); 225 + 226 + testWidgets('login normalizes identifier before starting OAuth', (tester) async { 227 + await tester.pumpWidget(buildSubject()); 228 + await tester.pumpAndSettle(); 229 + 230 + await tester.enterText(find.byType(TextFormField).first, ' @River.BSky.Social '); 231 + await tester.tap(find.byKey(const ValueKey<String>('login-continue-button'))); 177 232 await tester.pumpAndSettle(); 178 233 179 234 verifyInOrder([ ··· 182 237 ]); 183 238 }); 184 239 240 + testWidgets('invalid identifier shows centralized validation message and does not start OAuth', (tester) async { 241 + await tester.pumpWidget(buildSubject()); 242 + await tester.pumpAndSettle(); 243 + 244 + await tester.enterText(find.byType(TextFormField).first, 'not-a-handle'); 245 + await tester.tap(find.byKey(const ValueKey<String>('login-continue-button'))); 246 + await tester.pumpAndSettle(); 247 + 248 + expect(find.text('Enter a full handle like username.bsky.social'), findsOneWidget); 249 + verifyNever(() => settingsCubit.setAppViewProvider(any())); 250 + verifyNever(() => authBloc.add(const OAuthLoginRequested(handle: 'not-a-handle'))); 251 + }); 252 + 253 + testWidgets('auto-starts OAuth once when reauth opens with an initial handle', (tester) async { 254 + await tester.pumpWidget(buildSubject(initialHandle: 'alice.bsky.social', autoStartOAuth: true)); 255 + await tester.pumpAndSettle(); 256 + 257 + final field = tester.widget<TextFormField>(find.byType(TextFormField).first); 258 + expect(field.controller?.text, 'alice.bsky.social'); 259 + verifyInOrder([ 260 + () => settingsCubit.setAppViewProvider('bluesky'), 261 + () => authBloc.add(const OAuthLoginRequested(handle: 'alice.bsky.social')), 262 + ]); 263 + 264 + await tester.pump(); 265 + verifyNever(() => authBloc.add(const OAuthLoginRequested(handle: 'alice.bsky.social'))); 266 + }); 267 + 185 268 testWidgets('tints BlackSky logo in dark mode', (tester) async { 186 269 await tester.pumpWidget(buildSubject(themeMode: ThemeMode.dark)); 187 270 await tester.pumpAndSettle(); ··· 196 279 final blueSkySvg = tester.widget<SvgPicture>(blueSkyLogo.first); 197 280 expect(blueSkySvg.colorFilter, isNull); 198 281 }); 282 + 283 + testWidgets('saved account row tap restores stored session', (tester) async { 284 + final account = _makeAccount(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice'); 285 + final state = AccountSwitcherState.ready(accounts: [account], activeDid: account.did); 286 + const tokens = AuthTokens(accessToken: 'token', did: 'did:plc:alice', handle: 'alice.bsky.social'); 287 + when(() => accountSwitcherCubit.state).thenReturn(state); 288 + whenListen(accountSwitcherCubit, const Stream<AccountSwitcherState>.empty(), initialState: state); 289 + when(() => accountSwitcherCubit.switchAccount(account.did)).thenAnswer((_) async => tokens); 290 + 291 + await tester.pumpWidget(buildSubject()); 292 + await tester.pumpAndSettle(); 293 + 294 + expect(find.text('Saved accounts'), findsOneWidget); 295 + expect(find.text('Alice'), findsOneWidget); 296 + expect(find.text('@alice.bsky.social'), findsOneWidget); 297 + 298 + await tester.tap(find.byKey(const ValueKey<String>('saved-account-did:plc:alice'))); 299 + await tester.pumpAndSettle(); 300 + 301 + final field = tester.widget<TextFormField>(find.byType(TextFormField).first); 302 + expect(field.controller?.text, 'alice.bsky.social'); 303 + verify(() => accountSwitcherCubit.switchAccount(account.did)).called(1); 304 + verify(() => authBloc.add(const SessionRestored(tokens: tokens))).called(1); 305 + verifyNever(() => authBloc.add(const OAuthLoginRequested(handle: 'alice.bsky.social'))); 306 + }); 307 + 308 + testWidgets('saved account row tap starts OAuth reauth when stored session cannot be restored', (tester) async { 309 + final account = _makeAccount(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice'); 310 + final state = AccountSwitcherState.ready(accounts: [account], activeDid: account.did); 311 + when(() => accountSwitcherCubit.state).thenReturn(state); 312 + whenListen(accountSwitcherCubit, const Stream<AccountSwitcherState>.empty(), initialState: state); 313 + when(() => accountSwitcherCubit.switchAccount(account.did)).thenAnswer((_) async => null); 314 + 315 + await tester.pumpWidget(buildSubject()); 316 + await tester.pumpAndSettle(); 317 + 318 + await tester.tap(find.byKey(const ValueKey<String>('saved-account-did:plc:alice'))); 319 + await tester.pumpAndSettle(); 320 + 321 + final field = tester.widget<TextFormField>(find.byType(TextFormField).first); 322 + expect(field.controller?.text, 'alice.bsky.social'); 323 + verify(() => accountSwitcherCubit.switchAccount(account.did)).called(1); 324 + verifyInOrder([ 325 + () => settingsCubit.setAppViewProvider('bluesky'), 326 + () => authBloc.add(const OAuthLoginRequested(handle: 'alice.bsky.social')), 327 + ]); 328 + verifyNever( 329 + () => authBloc.add( 330 + const SessionRestored( 331 + tokens: AuthTokens(accessToken: 'token', did: 'did:plc:alice', handle: 'alice.bsky.social'), 332 + ), 333 + ), 334 + ); 335 + }); 336 + 337 + testWidgets('shows saved accounts loading state while account list is loading', (tester) async { 338 + when(() => accountSwitcherCubit.state).thenReturn(const AccountSwitcherState.loading()); 339 + whenListen( 340 + accountSwitcherCubit, 341 + const Stream<AccountSwitcherState>.empty(), 342 + initialState: const AccountSwitcherState.loading(), 343 + ); 344 + 345 + await tester.pumpWidget(buildSubject()); 346 + await tester.pump(); 347 + await tester.pump(const Duration(milliseconds: 200)); 348 + 349 + expect(find.text('Saved accounts'), findsOneWidget); 350 + expect(find.text('Loading saved accounts...'), findsOneWidget); 351 + expect(find.byType(CircularProgressIndicator), findsWidgets); 352 + }); 353 + 354 + testWidgets('remove saved account dispatches SessionCleared and hides section when list becomes empty', ( 355 + tester, 356 + ) async { 357 + final account = _makeAccount(did: 'did:plc:alice', handle: 'alice.bsky.social', displayName: 'Alice'); 358 + final initial = AccountSwitcherState.ready(accounts: [account], activeDid: account.did); 359 + const empty = AccountSwitcherState.ready(accounts: []); 360 + final states = StreamController<AccountSwitcherState>(); 361 + addTearDown(states.close); 362 + 363 + when(() => accountSwitcherCubit.state).thenReturn(initial); 364 + whenListen(accountSwitcherCubit, states.stream, initialState: initial); 365 + when(() => accountSwitcherCubit.removeAccount(account.did)).thenAnswer((_) async { 366 + states.add(empty); 367 + return const AccountRemovalResult.requiresSignIn(); 368 + }); 369 + 370 + await tester.pumpWidget(buildSubject()); 371 + await tester.pumpAndSettle(); 372 + 373 + expect(find.text('Saved accounts'), findsOneWidget); 374 + await tester.tap(find.byTooltip('Remove account').first); 375 + await tester.pumpAndSettle(); 376 + await tester.tap(find.text('Remove')); 377 + await tester.pumpAndSettle(); 378 + 379 + verify(() => accountSwitcherCubit.removeAccount(account.did)).called(1); 380 + verify(() => authBloc.add(const SessionCleared())).called(1); 381 + expect(find.text('Saved accounts'), findsNothing); 382 + }); 199 383 } 200 384 201 385 class _FakeTypeaheadRepository extends TypeaheadRepository { ··· 208 392 return searchHandler(query: query, limit: limit); 209 393 } 210 394 } 395 + 396 + Account _makeAccount({required String did, required String handle, String? displayName}) { 397 + return Account( 398 + did: did, 399 + handle: handle, 400 + displayName: displayName, 401 + service: null, 402 + oauthService: null, 403 + oauthClientId: null, 404 + accessToken: 'token', 405 + refreshToken: null, 406 + dpopPublicKey: null, 407 + dpopPrivateKey: null, 408 + dpopNonce: null, 409 + expiresAt: null, 410 + createdAt: DateTime.utc(2026, 1, 1), 411 + updatedAt: DateTime.utc(2026, 1, 1), 412 + ); 413 + }