diff --git a/CHANGELOG.md b/CHANGELOG.md index c61b32e57..64372936f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Changed + +- 全 App 界面重做:首页、版块、通知、聊天、帖子阅读与编辑、个人资料、商店、银行、社群、设置与账号页面统一为新版设计,保留原有功能、自定义主题/字体与账号隔离。侧栏提供首页、版块、活动、通知、我的,以及称号商店、勋章中心、银行、设置;首页整合签到、每日红包及「勋章与称号」入口,未读筛选移至通知顶栏。Windows/macOS/Linux 在可用宽度达 1100px 时,版块、子版块与分组页使用双栏大卡;Android/iOS 任何宽度均维持手机卡片。 +- 第二牌子(称号):首页、个人资料、帖子楼层作者区与作者弹窗显示论坛的第二牌子;作者弹窗优先使用楼层数据,只有作者 UID 与已验证登录账号相同时才补充本账号称号,不会把其他账号的称号套给作者。楼层内用户组牌子与第二牌子同高,作者弹窗与个人资料页同高、对齐一致,窄屏不溢出。 + +### Added + +- 首页:签到按钮在本 App 记录到当天已签到(本次或先前的手动/自动签到)后显示「已签到」;每日红包在本 App 记录到当天已领取后显示「红包已领取」,不再显示「暂无」。只记录 App 自己的操作,网页上完成的签到/领取仍需点一下才知道。 +- 设置:外观新增「帖子内容缩放」(1.0–2.0,步进 0.1),在全局文字缩放之上额外放大帖子页面的楼层内容,两者相乘后最高 3.0;页面标题栏、提示行与回复栏不受影响。(#137) +- 桌面端(Windows/macOS/Linux):按 Esc 或鼠标返回侧键可返回上一页;对话框、菜单与展开的回复编辑器会先关闭,文本输入框有焦点时不响应,主页各分页不会因此退出应用。(#138) + +### Fixed + +- 首页:Android/iOS 横屏不再套用桌面的宽版排版,问候卡片保持手机版:称号牌子不再放大;横屏时签到、红包、活动总览、勋章与称号四个按钮排成一行。 +- 帖子:标题一律靠左,短标题不再居中;下拉刷新完成后正确结束刷新指示器(回报的标题上方空白疑似由此造成,待实机确认)。 +- 设置:「主题模式」的切换按钮移到同一行右侧,不再占用下方一整行。 +- 回复:横屏时展开的编辑器不再比页面窄,底部回复框不会在两侧露出成第二个输入框(帖子、私聊、通知详情);移除编辑器顶部与「收起」按钮重复的拖动条。(#139) +- 帖子:手机横屏时点头像打开的作者卡片改为一行式头部,UID 与在线状态不再被裁掉。 + ## [1.28.5] - 2026-09-27 ### Added diff --git a/doc/spec-x5-features.md b/doc/spec-x5-features.md index 97ff3aad4..bdc27d775 100644 --- a/doc/spec-x5-features.md +++ b/doc/spec-x5-features.md @@ -1244,3 +1244,44 @@ B. 論壇提醒屏蔽規則 - 購買:確認框優先引用 `data-c` 原句、註明不自動佩戴;確認後以同帳號 repository 重讀當頁,核對 UID、表單、ID、名稱、價格、確認句,變動則不送出並要求重新確認;再以 `singleAttempt` POST 一次,之後 GET 商店,該列成為「已拥有」才算成功,`.alert_error` 且未擁有為被拒,其餘一律「結果未確認」且不重送。購買中鎖住連點、翻頁與重新整理;切帳號作廢流程、不顯示舊結果。購買表單只接受同源 `action=buy` POST、恰好 `formhash`/`tsdmtitle_return`(只回商店)/`buyid`(等於該列)三個隱藏欄位及 `buysubmit=true`。 - 設計見 `docs/specs/2026-09-27-medal-search-title-shop-design.md`。測試:`test_126`(搜尋連結/表單/狀態/畫面)、`test_127`(商店解析與表單驗證)、`test_128`(購買狀態、重驗、單次 POST、切帳號)、`test_129`(商店畫面、取消、鎖定、窄螢幕)。樣本皆為合成資料。 - 尚未實機驗證:真實購買的成功/失敗回應頁(未做購買),成功只以購買後商店狀態判定。 + +## 40. 全 App UI 預覽 110 回饋修正(PR #135、GitHub #139,2026-09-28) + +### 40.1 回覆編輯器 + +- Material 3 預設把 bottom sheet 限制在 640dp 寬;手機橫向(792dp)時展開的編輯器比頁面窄,底部收合的回覆框從兩側露出來,看起來像兩個輸入框。`ReplyBar` 開 sheet 時改傳空的 `BoxConstraints`,sheet 與回覆列一樣貫穿整頁,內容仍靠自己置中到閱讀欄寬。 +- 編輯器頂部的拖動條移除(#139):與右下角「收起」按鈕功能重複,只佔空間;向下滑仍可關閉。 + +### 40.2 首頁簽到/紅包狀態 + +- 簽到:`CheckinBloc` 新增 `CheckinStatusRequested`,讀 `CheckinRepository.checkedInToday(uid)`(cookie 表的 `lastCheckin` 是否為本機今天),是則進 `CheckinStateChecked`;首頁載入成功後送出。按鈕在 `CheckinStateChecked`、`CheckinStateSuccess`、`CheckinStateFailed(CheckinResultAlreadyChecked)` 時顯示「已簽到」並停用,tooltip 說明是本 App 的記錄。只知道 App 自己做過的簽到(手動或自動);網頁上簽到的要再點一下,論壇回「已經簽到」後才會記錄。 +- 紅包:論壇首頁只有在尚未領取時才嵌 `hongbaoDailyInit`,領過後頁面與沒有紅包時一樣。App 在領取成功或論壇回「已領過」時,把該帳號的 `dateflag` 存到設定表 `dailyRedPacketClaimed.`;首頁沒有紅包時,若記錄的 `dateflag` 等於本機今天(`YYYYMMDD`),顯示「紅包已領取」,tooltip 說明是 App 的記錄。頁面上有紅包時一律以頁面為準。站方的日界未知,以本機日期近似,跨午夜前後可能差幾小時。 +- 不會為了狀態多打任何請求;不會把「沒有紅包」直接說成「已領取」。 + +### 40.3 作者牌子 + +- 樓層作者列:用戶組牌子與第二牌子同高 `postAuthorBadgeHeight`(32dp),第二牌子依 184:100 比例約 59dp 寬(原本 120/138dp,明顯大過用戶組牌子)。 +- 作者彈窗:兩枚牌子同高 72dp;個人資料頁的稱號區塊改為置中,與用戶組區塊一致。 + +### 40.4 驗證 + +- `test/regression/test_172_feedback111_test.dart`:三種視窗(橫向含瀏海、直向、寬視窗)編輯器貫穿整頁且無拖動條、收起後回覆列回來;簽到記錄今天/昨天/換帳號/未登入;紅包記錄今天/別天/別帳號、頁面有紅包優先、領取成功與「已領過」會記錄、失敗不記錄;牌子尺寸換算。 +- 未實機驗證:Android 橫向鍵盤面板、真實論壇簽到與紅包流程。 + +### 40.5 第二輪(preview112 回饋) + +- 首頁排版:`homeLayoutFor(width, platform)` 與版塊列表同一原則——Android/iOS 任何寬度都是 compact;只有桌面平台依寬度進 medium(≥600)/wide(≥960)。橫屏手機原本被當成寬視窗,問候卡片的稱號牌子放大到 240dp、簽到/紅包/活動/勳章四顆按鈕排成不整齊兩行。 +- 設定「主題模式」:三段式切換鈕改為列尾 `trailing`,與標題同列;副標仍顯示目前模式。2 倍字級下仍在視窗內(test_172 第 4 組)。 + +### 40.6 第三輪(preview113 回饋) + +- 首頁問候卡片(compact):內容寬 ≥ 560×字級 時四顆按鈕(簽到、紅包、活動總覽、勳章與稱號)排一行;≥ 300×字級 維持簽到/紅包一行+入口第二行;更窄全部直疊。 +- 帖子標題:`AppContentWidth` 置中子元件,短標題原本因此看起來置中、長標題靠左;標題改包 `SizedBox(width: double.infinity)` 一律靠左。 +- 標題上方空白:`EasyRefreshController(controlFinishRefresh: true)` 但從未呼叫 `finishRefresh()`,下拉更新後 header 停在 processing、在 locator 位置留下 100dp 空白(新 UI 重新載入時列表不再被拆掉重建,所以顯露出來)。改為 build 時 `finishRefresh()` 與 `finishLoad()` 一起結束。test_171 加下拉更新案例(測試環境下修正前後都沒有空白,未能重現回報畫面;此修正依 EasyRefresh 的 API 契約補上,實機效果待測試者確認)。 + +## 41. 貼文內容縮放、桌面端 Esc/滑鼠返回鍵(GitHub #137、#138,2026-09-28) + +- 貼文內容縮放:設定 → 外觀新增 `threadContentScale`(double,預設 1.0,範圍 1.0–2.0、步進 0.1),存於 drift `settings` 表既有的 `double_value` 欄位,不需 migration。貼文頁(`ThreadPage`)只對樓層列表(`PostList`/`PostCard`)套用 `MediaQuery.textScaler = 全域文字縮放 × 貼文內容縮放`,乘積上限 3.0;標題列、軟關閉提示與回覆列維持全域縮放。所有平台皆可用。 +- 桌面返回鍵:`DesktopBackHandler` 掛在 `MaterialApp.router` 的 builder(`lib/app.dart`),只在 `isDesktop` 生效。Esc(按下)與滑鼠返回側鍵(`kBackMouseButton`)以主焦點所在的路由判定:只處理 `PageRoute` 且非該 navigator 首頁的情況,呼叫 `Navigator.maybePop`,因此頁面的 `PopScope`(例如草稿確認)仍然生效,主頁各分頁(首頁/主題/設定的殼路由)不會觸發退出 App。對話框、彈出選單、modal bottom sheet 不是 `PageRoute`,Esc 交還給路由自身的 dismiss 動作(可點遮罩關閉者才關閉);焦點在文字輸入(`EditableText` 或任何實作 `TextInputClient` 的編輯器,含 BBCode 編輯器)時 Esc 與側鍵都不動作;展開的回覆編輯器是頁面的 local history entry,`maybePop` 會先收起編輯器、再按一次才離開頁面。右鍵不使用。 +- 測試:`test_173`(設定套用到樓層、不影響標題列與回覆列、與全域縮放相乘與上限)、`test_174`(可返回時 Esc/側鍵 pop、根頁不動、輸入框有焦點不動、對話框與持久 bottom sheet 先關閉、`PopScope` 拒絕時不 pop、非桌面停用)。 +- 尚未實機驗證:未在 Windows 實機測 Esc/滑鼠側鍵(含 BBCode 編輯器焦點下的行為)與放大後的實際觀感;測試以 Linux 桌面環境的 widget test 為準。 diff --git a/docs/specs/2026-09-27-full-ui-design.md b/docs/specs/2026-09-27-full-ui-design.md new file mode 100644 index 000000000..eeaac9dde --- /dev/null +++ b/docs/specs/2026-09-27-full-ui-design.md @@ -0,0 +1,157 @@ +# 全 App UI 重做(沿用已批准首頁風格) + +使用者已批准:整個 App 沿用 `work/home-ui-reference` 與目前原生首頁的設計語言,全部頁面一致;全部批次完成並通過 build/analyzer/tests 後才交測試員。本文件是已授權的固定方案與分批計畫,不再另行詢問。 + +## 固定設計方案 + +### 共享元件(`lib/widgets/app_surface.dart`) + +| 元件/常數 | 用途 | +|---|---| +| `appSurfaceRadius` = 18、`appInnerRadius` = 12 | 內容 surface 與內部小區塊圓角;首頁 `homeCardRadius`/`homeCardShape` 改為引用此值 | +| `appSurfaceShape(context)` | 圓角 + `outlineVariant` 60% 髮絲邊框;同時設為全域 `CardTheme`,所有 `Card` 自動一致 | +| `AppSurface` | 圓角 surface,可點擊(InkWell),預設內距 16/12 | +| `AppSectionHeader` | 區段標題(粗體 titleSmall、可選圖示與右側動作) | +| `AppContentWidth` | 內容置中、限制最大寬度(列表 960、閱讀 920、表單 760) | +| `AppCenteredList` + `appCenteredPadding` | 捲動視圖保持全寬(下拉重新整理、捲軸),列以 padding 置中;以實際量到的寬度計算,側欄/rail 也正確 | +| `appColumnsFor`/`appRowCount`/`AppColumnsRow` | 寬度 ≥ 840 時卡片兩欄,手機單欄 | +| `AppStateView` | 空白/失敗/資訊狀態:圓角圖示塊 + 文案 + 可選動作,窄螢幕可捲動不溢出;第 2 批加 `scrollable: false`(已在捲動視圖內時用) | +| `AppScrollableStateView`(第 2 批) | 把 `AppStateView` 撐滿可下拉重新整理的捲動視圖,空列表也能下拉 | +| `AppIconTile`(第 2 批) | 圓角方形圖示塊(通知、同步摘要、收藏/歷史卡片的前導) | +| `AppInfoPill`(第 2 批) | 卡片標題下的小型資訊膠囊(作者、版塊、時間、計數),`Wrap` 換行、長字省略 | +| `AppPager`(第 2 批) | 分頁切換:上一頁/「目前 / 總頁」(開跳頁)/下一頁,提示用 `MaterialLocalizations` | +| `AppFormSection`(第 3 批) | 表單分組 surface:可選區段標題+圖示,子欄位固定間距(發文投票、評分、模板編輯) | +| `AppInsetBlock`(第 3 批) | surface 內的圓角小區塊(引用、論壇回傳訊息、預覽、統計格),可選髮絲框 | +| `AppNoticeBanner`(第 3 批) | info/warning/error 三種提示橫幅,可帶標題、可選取文字、動作按鈕(軟關閉、被拒、預檢失敗、投票狀態) | +| `AppEmbedCard`+`appEmbedColor`(第 3 批) | 樓層內特殊區塊外框:淡色圓角+髮絲框、圖示塊標題、副標、右側動作;巢狀時改較高容器色取代陰影 | +| `AppBottomActionBar`(第 3 批) | 表單底部固定操作列(髮絲線、SafeArea、置中寬度),鍵盤開啟時隨頁面上移 | +| `EditorFrame`/`EditorControlBar`(第 3 批,`features/editor/widgets/editor_frame.dart`) | 富文字編輯區圓角框(聚焦時主色邊框);編輯控制列(左側可捲動、右側固定) | +| `ErrorCard`(既有 API) | 改用 `AppSurface` 與同款圖示塊,全 App 的 `buildRetryButton` 一起更新 | +| `AppDialogTitle`(第 5 批) | 對話框標題:圖示塊+可換行標題,只用 `Row`+`Expanded`(`AlertDialog` intrinsic 安全);`error: true` 用錯誤容器色(破壞性確認) | +| `AppTileGroup`(第 5 批) | 設定類頁面的分組:一個 surface 內區段標題+列(`ListTile`/`SwitchListTile`)+髮絲分隔線+可選頁尾(通常是 `AppNoticeBanner`);列的水波紋被圓角裁切 | +| `appFieldDecoration`(第 5 批) | 表單欄位共用裝飾:填色、圓角 12、可選前置圖示與後綴,說明/錯誤最多 3 行 | + +### Theme(`lib/themes/app_themes.dart`) + +- 淺色/深色共用 `_finish`:在 FlexColorScheme 產生的主題上覆寫 Card(圓角 + 髮絲邊框、elevation 0)、PopupMenu(圓角 12)、Dialog(圓角 24)。以 `base.xxxTheme.copyWith` 保留 Flex 原有顏色。 +- 不改種子色、字體、字級:使用者自訂主題色、自訂字體與文字縮放照舊生效;帖子內文仍走 `MunchedHtml` 與既有閱讀設定。 + +### 版面規則 + +- 手機(< 600):單欄,側邊距 12(帖子樓層 6,保留閱讀寬度);列與列間距 8。 +- 平板/桌面:列表置中最寬 960、帖子最寬 920、表單與個人資料最寬 760;版塊列表 ≥ 840 兩欄。 +- SafeArea 與既有 `context.safePadding()` 保留;表單頁仍由 Scaffold 處理鍵盤,內容可捲動。 +- 只顯示真實資料,不使用 HTML 原型的虛構資料;無資料時顯示既有「沒有資料」類文案,不捏造。 +- 新可見文字一律走既有三語 i18n;第 1 批沒有新增字串(沿用 `general.retry`、`general.failedToLoad`、`profilePage.secondaryTitle` 與 Flutter 內建 `MaterialLocalizations` 的翻頁提示)。第 2 批新增一條 `noticePage.syncAllPage.finished`(Finished/已完成/已完成),其餘沿用既有字串(`topicPage.threads/posts/today` 當版塊計數提示、`profilePage.online/offline` 當聊天狀態、分頁與展開提示用 `MaterialLocalizations`)。 +- 未讀強調(第 2 批):通知卡片只在該類未讀紅點設定開啟時才加底色與粗標題;分頁標籤的未讀數同樣遵守設定,私訊不計入本機封鎖(靜音)對象,與徽章規則一致。 +- 聊天氣泡(第 2 批):只有能確認是目前帳號(歷史頁以 uid、對話頁無 uid 時以使用者名稱)才靠右;無法確認一律當作對方,不猜測歸屬。 +- 表單與編輯器(第 3 批):表單頁置中 760(發文/回覆編輯器 920),欄位依寬度與字級決定每列數量(不用固定高度格,錯誤與說明文字不裁切);主要送出鈕固定在底部操作列;對話框內不用 `LayoutBuilder`(`AlertDialog` 以 intrinsic 尺寸排版),寬度由 `briefProfileDialogContentWidth` 類的計算或 `Wrap` 處理。第 3 批沒有新增 i18n 字串(沿用 `forumPage.rulesTab.title`、`jumpDialog.title`、`ratePostPage.title`、`general.noData` 與 `MaterialLocalizations`),不需要跑 slang。 +- 樓層內區塊(第 3 批):上鎖、出售、投票、紅包、評分、代碼、懸賞、最佳答案、點評、隱藏內容共用 `AppEmbedCard`;引用改左側色條區塊。只改外觀,HTML/BBCode 解析、購買、投票、領取流程不變;X5 紅包信封保留論壇配色。 +- 第 3 批修正:`AppContentWidth` 的 `Align` 會吃滿宿主給的高度,只能放在高度不受限(Column 等)或本來就該填滿的位置;高度有界但寬鬆的宿主(例如 `Scaffold.bottomNavigationBar`)要用 `heightFactor: 1`(回覆列收合框已改)。窄螢幕/大字級時,列尾動作鈕若會把說明文字擠成一字一行,就移到文字下方(封鎖樓層佔位:寬度 < 420×字級倍率)。選單一律依用途分組、以 `PopupMenuDivider(height: 8)` 分隔、文字可換行,破壞性或回報類動作(檢舉)用錯誤色;外部/本地套件產生的按鈕不改套件,只用 `IconButtonTheme` 統一圓角。帖子列表卡片頭部不用 `ListTile`(固定行數會裁切),改頭像+名稱+可換行的日期/版塊行+右側標記。 +- 商店、銀行、社群(第 4 批):不新增共享元件,沿用 `AppSurface`/`AppFormSection`/`AppInsetBlock`/`AppNoticeBanner`/`AppStateView`/`AppInfoPill`/`AppIconTile`。稱號圖一律固定 184:100 比例、`BoxFit.contain`(卡片 138 寬或窄時最寬 184,對話框 138 固定尺寸),勳章圖固定正方形 contain。金額、價格、期限、購買限制、論壇確認句與收費說明只換外框,不縮寫、不省略、不改字串;交易類確認一律兩步(輸入 → 確認摘要 → 送出),摘要放外框區塊,交易說明用 warning 橫幅,破壞性(銷戶)用 error 橫幅。伺服器停用的動作除 Tooltip 外把原因以文字列出(觸控裝置不必長按)。列表頁置中 960、寬 ≥ 840 兩欄(商店、勳章、銀行列表、活動、好友、自動簽到);銀行詳情寬時活期與紀錄並排;黑名單兩頁置中 760。所有帳號隔離、請求競態、確認與鎖定邏輯不變。第 4 批新增 i18n `userBlock.localTitle`(Blocked users (this device)/已屏蔽用户(本机)/已屏蔽用戶(本機)),需跑 slang。 +- 設定、帳號、工具與個人資料(第 5 批):設定頁每個區段一個 `AppTileGroup`(帳號/外觀/未讀提示/視窗〔桌面〕/簽到/行為/訊息同步〔含 Android 權限與背景服務〕/儲存/進階〔代理〕/備份與還原/除錯/其他),手機單欄置中 760,寬 ≥ 1200 兩欄(外觀類在左、行為與資料在右,最寬 1240);主題模式改為列下方的圖示分段鈕(原本擠在列尾的 ToggleButtons 在 320 寬 2 倍字會擠壓標題),各項說明原文保留、限制說明改頁尾 info 橫幅。所有設定彈窗改 `AppDialogTitle`、填色欄位、主要動作 `FilledButton`、數值型(同步間隔、圖片清理、字級)改「目前值區塊+滑桿+兩端標示」、字型與字級附預覽;備份匯出/還原對話框的安全說明、兩次密碼、只能按鈕關閉(`barrierDismissible: false`、`PopScope`)與資料格式不變。通用 `showQuestionDialog`/`showMessageSingleButtonDialog` 加圖示標題(`dangerous` 時錯誤色),按鈕仍是兩個 `TextButton`、順序不變;`showCustomBottomSheet` 標題與關閉鈕同一列(長標題省略,不再壓在關閉鈕下)並加拉把。登入頁改整頁單一捲動(原本是置中 500 高的清單,鍵盤/大字時空間不足),登入方式改可換行的選擇晶片,帳號與安全問題兩組,失敗時表單內加錯誤橫幅(snack bar 照舊);只預填呼叫端傳入的帳號名稱,不讀其他帳號資料。帳號管理頁置中、帳號一組、「新增帳號」獨立列;帳號對話框只顯示該帳號自己的名稱/UID/目前狀態,破壞性動作放最後並用錯誤色,所有確認對話框照舊。更新頁顯示目前版本與檢查結果(檢查到新版時在頁內列出版本與更新紀錄,因為全域更新提示在更新頁不出現),更新提示對話框改圖示標題、版本膠囊、更新紀錄外框(「更新」仍是 `TextButton`,只加 tonal 底色)。圖片檢視保留全頁 `PhotoView`(捏合/雙擊縮放、拖曳),下方浮動控制列:適應螢幕、統計資訊、圖片動作(儲存、複製網址、重新載入、存為貼圖;不再重複「檢視大圖」)。個人資料頁:無頭像時頭部改主題色漸層(原本整塊空白)、頭像白框陰影;身分資訊一個 surface;簡介/簽名、使用者組、第二稱號、勳章、管理版塊、簽到、活動、統計各為一個 surface;簽到、活動與統計改「依寬度與字級決定欄數」的數值格(每列等高,取代固定 70px 的 3 欄格),IP 仍預設遮蔽。第二稱號的資料來源與帳號隔離不變。資料編輯分「基本資料/聯絡方式/關於我/論壇顯示」四組,頂部提示「修改需按上傳才送到論壇」,欄位對話框改圖示標題與填色欄位。支持開發對話框保留真實支付寶 QR(白底圓角框以利深色模式掃描)、另存、功能許願連結與自願支持說明,只調整分組與視覺,不新增任何付款流程。 +- 第 5 批新增 i18n(三語):`settingsPage.advancedSection.backupTitle`、`settingsPage.appearanceSection.threadCard.preview`、`aboutPage.linksSection`、`aboutPage.buildSection`、`imageDetailPage.fitToScreen`、`editUserProfilePage.sections.{basic,contact,aboutMe,forum}`、`editUserProfilePage.uploadHint`,需跑 slang;generated 未手改。 +- 導覽(第 2 批):底部導覽列、rail、側欄 drawer 共用低容器底色、圓角 12 指示器與髮絲分隔線;rail 改為常駐標籤。導覽項目與路由不變(首頁/分區/設定)。 + +## 第二牌子(稱號,原圖 184×100) + +| 位置 | 來源 | 尺寸 | +|---|---|---| +| 首頁問候卡右上 | `CurrentTitleCubit`(目前帳號的稱號頁),只對目前帳號 uid | **2026-09-27 使用者細修取代舊目標**:寬版 240(放大超過原圖 184,仍 184:100 contain,不經 `fitWidth` 的原寬上限);手機 160–184(依內容寬 45%);不超過卡片寬;問候文字剩餘寬度 < 168 時換行到問候下方。(舊:桌面 184、手機 120–138) | +| 自己的個人資料 | 個人資料頁本身若有稱號區塊則用之;否則(僅本人)用 `CurrentTitleCubit` | 最大 184,完整顯示 | +| 別人的個人資料 | 只用該個人資料頁本身的稱號區塊(樣本尚未證實存在),絕不套目前帳號 | 同上;無資料不顯示 | +| 帖子作者區 | 該樓層作者欄 `div.tsdmtitle-badges div.tsdmtitle-title > img`(X5 樣本 #011 已驗證),舊版 `img.tsdmtitles`/`img.tsdm_lv_title` 後備 | 手機 120、寬螢幕 138;與等級牌子 `Wrap` 換行,不硬塞 | +| 作者彈窗 | 同一樓層資料;樓層沒有且作者 uid=目前已驗證帳號時,改用 `CurrentTitleCubit`(2026-09-28 回饋 110),其他作者絕不套用 | 最大 184(依對話框內容寬度),與等級牌子 `Wrap` 換行 | +| 我的稱號/勳章稱號中心 | 稱號頁/`CurrentTitleCubit` | 最大 184,空間不足時改直排 | + +狀態:讀取中保留牌子大小的佔位;讀取失敗顯示「重試」方塊(Tooltip/語意標籤為「第二稱號 · 載入失敗」),不顯示成「未佩戴」;成功且未佩戴才什麼都不顯示。狀態只對應同一 uid(`CurrentTitleState.statusFor`),其他使用者不會看到目前帳號的佔位或重試。 + +已知風險:使用者截圖中作者卡只見等級牌子。原始碼解析在 X5 樣本可取得第二牌子,舊版 76px 太小且與等級牌子擠在同一列;本批已放大並換行。若該作者本身未佩戴、或論壇對某些頁面輸出不同標記(目前沒有樣本),仍會不顯示,需要測試員以真實帖子確認。未登入時 X5 作者欄為空,無法取得。 + +## 分批計畫 + +| 批次 | 範圍 | 狀態 | +|---|---|---| +| 1 | 共享元件/Theme;App shell 與導覽;首頁;分區、版塊群組、版塊、搜尋、最新帖、我的帖子、收藏、歷史的列表面;帖子閱讀(v1、v2 路由)、作者區、作者彈窗、跳頁控制;個人資料、我的資料、使用者組、稱號展示、資料編輯、頭像 | 本批實作(細節見 `work/full-ui-coverage.md`) | +| 2 | 消息/聊天/通知(含搜尋、同步全部、廣播詳情、回覆詳情)、搜尋頁表單/結果列/分頁、版塊篩選列與篩選表單、底部導覽列/rail/drawer、版塊/收藏/歷史卡片內容層級 | 已實作,Codex 已跑 slang/format/strict analyze/相關測試 PASS | +| 3 | 發文/回覆編輯器與回覆列、草稿箱、投票、評分與評分紀錄、快速回覆/評分模板、檢舉、編輯器彈窗、價格/權限、閱讀控制(麵包屑、`ListAppBar` 頁碼與選單、操作/出售紀錄、複製、封鎖佔位、軟關閉)、樓層內卡片與引用、樓層操作選單、帖子卡片頭部、編輯器工具列外觀、版塊 FAB/版規、收藏備註、作者簡介剩餘區段 | 已實作,Codex 已跑 format/strict analyze/相關測試 PASS | +| 4 | 稱號商店(含購買確認)、勳章中心(含購買/申請對話框)、銀行(列表、活期、紀錄、服務、存取款與服務確認)、活動、成就、積分(統計、紀錄、篩選)、紅包詳情、自動簽到與 `AutoCheckinUserCard`、紅包對話框、好友頁/卡片/選擇器/加好友/同意好友、本機封鎖、網站黑名單、通知忽略動作 | 已實作,Codex 已跑 slang/format/strict analyze/相關測試 PASS | +| 5 | 設定所有區段與設定彈窗、備份對話框、帖子卡片外觀頁、登入/需登入、帳號管理與帳號對話框、首頁使用者選單、更新頁/更新提示/更新紀錄、關於、授權頁外框、贊助、除錯紀錄(歷史/詳情)與除錯展示、於 App 開啟、圖片檢視控制列、個人資料頁剩餘區塊、資料編輯分組與欄位/生日對話框、頭像預覽、分區版主列、通用問答/訊息對話框與底部表單框架 | 已實作,**未驗證**:待 Codex 跑 slang/format/strict analyze/相關測試(新測試 `test_167_full_ui_phase5_test.dart`) | + +每批:只做 UI 結構/閱讀層級/響應式改造,保持路由、帳號隔離、通知徽章、樓層/跳轉/分頁、封鎖過濾、BBCode/HTML 解析與網路交易語意;不重寫 repository、不新增交易。 + +## 驗證 + +每批以 `slang`(若有新字串)、`build_runner`(若有 mapper 變動)、`dart format`、`flutter analyze`、`flutter test` 驗證;少量 widget test 覆蓋窄螢幕、長文字與第二牌子(第 1 批:`test/regression/test_163_full_ui_phase1_test.dart`;第 2 批:`test/regression/test_164_full_ui_phase2_test.dart`;第 3 批:`test/regression/test_165_full_ui_phase3_test.dart`,含鍵盤開啟時底部送出列仍可點;第 4 批:`test/regression/test_166_full_ui_phase4_test.dart`,稱號圖比例、320 寬 2 倍字購買確認與銀行確認、寬螢幕兩欄、自動簽到卡片換行;第 5 批:`test/regression/test_167_full_ui_phase5_test.dart`,320 寬 2 倍字的破壞性問答(按鈕型別與順序、回傳值)、備份匯出(鍵盤開啟、兩個密碼欄、回傳密碼)與還原錯誤文字、同步間隔/圖片清理不在選項中的預設值(原本會超出索引)、字級與語言回傳值、設定分組列可切換、需登入狀態單一登入鈕)。 + +## 導覽與首頁細修(使用者追加需求,2026-09-27)— 程式已改,未驗證 + +依使用者提供的 `work/ui-navigation-reference/reference-sidebar.png`(左欄分組)與 `current-greeting.png`: + +- **側欄(drawer 大視窗、rail 中寬)**:9 個真實入口兩組——首頁、版塊、活動、通知、我的;「更多功能」:稱號商店、勳章中心、銀行、設定。沿用 App 主題色(深淺色/自訂色),不強制參考圖的紫色。首頁/版塊/設定是 shell tab(保留分支狀態、雙擊回頂送 `HomeTab.index`);其他 6 項 `pushNamed` 蓋在 shell 上,返回回到原 tab,選取高亮只給 tab,不把 9 個索引塞進 `HomeTab`。通知入口的未讀徽章只用 `NotificationStateCubit`,規則同 AppBar 通知鈕;無帳號點通知開登入頁。rail 標籤常駐、組間 16px 間距,包在不佔 PrimaryScrollController 的捲動區,短視窗不溢出。手機底部列維持 3 個 tab,其他入口沿用首頁既有入口。 +- **文案**:導覽「分割區」改「版塊」(簡「版块」、英「Forums」),僅 `navigation.topics` 與 `topicPage.title`。 +- **每日紅包入口**:問候卡簽到旁常駐 `DailyRedPacketEntry`。可領(config+formHash)→原 `claimDaily`,單次提交防連點;伺服器確認領到/已領→「今日紅包已領取」停用(只針對該頁面的紅包);頁面沒有紅包時一律顯示「目前無可領取」並可點擊重新整理首頁(tooltip 說明可能已領或未開放)——repo 沒有站點時區依據,不以推測日期宣稱今日已領(2026-09-27 修正移除原 UTC+8 推測);無帳號→「登入後可領取」。不新增協定、不持久化、不假造可領。 +- **問候卡第二牌子**:見上表,寬版 240、手機 160–184。 + +## 全 UI 自動驗證結果(2026-09-27) +五批實作與測試發現的修正已完成。slang、format、嚴格分析通過,完整 Flutter 測試 1629 通過/1 略過。長標題對話框在 320px/2 倍字且鍵盤開啟時保留可捲動完整文字與固定動作;登入表單改為一次建立所有欄位以確保驗證與重試可達。Android/Windows 預覽建置與實機驗收仍待完成,禁止據此宣稱已發布或實機測試。 + +## 追加導覽驗證(2026-09-27) +導覽細修及其修正已通過 slang、format、嚴格分析與完整 Flutter 測試:1648 通過、1 略過、0 失敗。未讀徽章的測試等待修正保留並加強正負例,紅包移除未確認的時區假設。預覽106建置和實機驗收待完成。 + +## 版塊頁桌面放大(使用者 preview106 回饋,2026-09-28)— 程式已改,未驗證 + +回饋:「電腦版的版塊太小」(`work/ui-topic-reference/desktop-too-small.png`:寬視窗內容只佔中間約 960,四張卡、版塊圖、標題、統計都小)。根因:版塊頁用共用 `AppCenteredList` 預設 `appListMaxWidth` 960,`ForumCard` 在所有寬度都用手機密度(圖 88×44、`titleMedium`、`labelSmall` 統計)。 + +- **只改版塊頁**:`topics_page.dart` 依 `LayoutBuilder` 量到的頁面實際寬度(扣掉側欄,不看 Platform)判斷:寬 ≥ `topicsPageLargeCardWidth` 1100 → 內容區最寬 `topicsPageMaxWidth` 1320,仍兩欄,卡片/欄距 12;未達 1100 → 與改前完全相同(960、一欄→840 起兩欄、間距 8)。全域 `appListMaxWidth` 不動,其他列表頁不變。 +- **大卡(`ForumCard(large: true)`,只有版塊頁寬版傳入)**:內距 20/18;版塊圖 136×68 且 `BoxFit.contain`(完整呈現、小圖會放大;精簡卡維持原本只縮不放);名稱 20、w600、最多兩行;時間 `bodyMedium` 14;三個統計 `AppInfoPill(large: true)`(圖示 17、`labelLarge` 14、內距 10/5);捷徑區(最新主題、連結、子版塊)字級各升一級。無圖 fallback、點卡片進版塊、統計 tooltip、捷徑設定不變。 +- **版主列**:`GroupModeratorsRow(large: true)` 內距 16/12、圖示 22、標籤 `titleSmall`、人名 chip `labelLarge` 標準密度;與卡片同一 ListView,左右邊緣對齊。 +- 字級全走 theme+`TextScaler`,系統/App 文字縮放照常作用;1100 寬 2 倍字時名稱換行/省略、統計 Wrap 換行。 +- 新增共用參數(預設值保持原樣):`AppInfoPill.large`、`NetworkIndicatorImage.fit`(轉給 `CachedImage.fit`)、`ForumCard.large`、`GroupModeratorsRow.large`。 +- 測試:`test/regression/test_169_topics_desktop_test.dart`(1600 寬:內容 1320 置中、兩欄、卡寬 654、版主列同邊緣、圖 136×68、名稱 20、統計 large;1000 寬:960、兩欄、88×44、非大卡;320 寬 2 倍字長名稱:單欄、捲動無例外;1100 寬 2 倍字長名稱:大卡兩欄、捲動無例外;大卡與版主 chip 導覽仍正確)。 + +## 電腦版版塊驗證(2026-09-28) +format、嚴格分析通過;新 test169 五項尺寸/大字/導覽測試通過,完整 Flutter 測試1653通過/1略過/0失敗。Codex補上新測試漏掉的shared models引用,並以ActionChip本體作點擊目標。預覽107建置與實機驗收待完成。 + +## 版塊大卡填滿(使用者 UI107 回饋,2026-09-28)— 程式已改,未驗證 + +回饋:「還是有點小,讓整體再每格滿版一點」(`work/ui-topic-reference/desktop-card-too-empty.png`:兩欄四卡,圖 136×68、名稱 20,卡片右半大片空白,統計是左下三個小膠囊)。取代上一節大卡的數值;< 1100 頁寬與其他 `ForumCard` 使用者不變。 + +- **內容區**:`topicsPageMaxWidth` 1320 → 1520,仍兩欄(1600 寬視窗卡寬 754)。 +- **大卡版頭依卡片實際內容寬與字級自適應**(`forumCardLargeHeaderLayout(contentWidth, textScale)`,卡內 `LayoutBuilder`):版塊圖寬 = 內容寬 × 0.42,限制在 180–240,高 = 寬 / 2(最大 240×120,`BoxFit.contain` 完整呈現、小圖放大);圖與名稱間距 20。名稱需要 ≥ 220 × 文字縮放的空間才並排,否則(窄桌面+大字)版塊圖保持 240×120、名稱改放圖下方。兩欄同寬,所以同列兩卡的圖尺寸、圖頂、名稱起點一致。 +- **名稱/時間**:名稱 24、w600、行高 1.3、最多兩行省略;時間 16(`bodyLarge`,secondary)前加 18px 時鐘圖示。 +- **統計**:新 `ForumCardStat`,三個等寬區塊(間距 12)橫跨整個卡片底部:上排 20px 主色圖示+說明文字 15(由 `topicPage.threads/posts/today` 去掉結尾冒號,無新 i18n),下排數字 20 w700;數字用 `FittedBox.scaleDown` 保證不被截斷(窄區塊+大字只縮小不溢出);tooltip 仍是原本「主題: 123」全文。大卡不再使用 `AppInfoPill`(`AppInfoPill.large` 保留未刪,其他頁不受影響)。 +- **內距**:24/20(`forumCardLargePadding`),版頭與統計間 18。捷徑區、點卡片進版塊、無圖 fallback、tooltip、收藏分組、版主列不變。 +- 測試 `test_169` 調整:1600 寬 → 內容 1520、卡寬 754、圖 240×120 且在名稱左側、名稱 24、兩欄圖尺寸/圖頂/名稱位置一致、三統計等寬填滿底部(圖示 20、數字 20);1000 寬與 320@2x 另外斷言仍是 88×44 圖+三個小膠囊、沒有 `ForumCardStat`;1100@2x 長名稱+12–14 位數統計 → 名稱在圖下方、圖 240×120、統計填滿、捲動無例外;新增 1100@1x 長名稱+長統計 → 圖在 180–240 間並排、名稱不越界、捲動無例外。 + +## 桌面卡片第二次放大驗證(2026-09-28) +Codex 已執行格式、嚴格分析並通過完整 Flutter 測試:1654 通過、1 略過、0 失敗。涵蓋 1600 寬大卡與全寬統計、1100 寬長名稱/長數字/2倍字、1000 與320寬手機精簡卡保持原樣,以及導航。Windows 預覽108待建置與實機確認;手機布局不變。 + +## 子版塊與分組頁大卡(使用者 108 回饋,2026-09-28)— 程式已改,未驗證 + +回饋:「子版塊都很小需要調整」(`work/ui-topic-reference/subforums-too-small.png`:版塊內「子版塊」tab 仍 960 寬、88×44 小圖)。根因:`forum_page.dart` `_buildSubredditTab` 與 `forum_group_page.dart` `_buildContent` 仍用 `AppCenteredList` 預設 960,`ForumCard` 未傳 `large`。`rg ForumCard(` 的 lib 使用處只有這兩處與 `topics_page`。 + +- **共用規則移到 `widgets/card/forum_card.dart`**:`forumCardListMaxWidth` 1520、`forumCardListLargeWidth` 1100、`forumCardListUsesLargeCards(width)`、`forumCardListLayout(width)`(回傳 large、欄數、左右置中內距、欄距、列分隔)。forum 層不再需要 import topics。`topics_page` 改用它;`topicsPageMaxWidth`/`topicsPageLargeCardWidth`/`topicsPageUsesLargeCards` 保留為同值別名,test_169 不需改。 +- **子版塊 tab、gid 分組頁**:與版塊頁相同——`LayoutBuilder` 量到的頁寬(兩頁 body 都在 `SafeArea(bottom: false)` 內,已扣掉 Android 橫向左右安全區與側欄)≥ 1100 → 內容最寬 1520 置中、兩欄、`ForumCard(large: true)`、欄距/列距 12、上 16 下 20;< 1100 → 與改前完全相同(960、一欄→840 起兩欄、間距 8、88×44)。底部仍加 `context.safePadding()`。 +- 未動:ScrollController、EasyRefresh/下拉重整、空狀態、載入/錯誤、自動切到子版塊 tab、雙擊回頂、FAB、帖子列表、卡片本身。 +- 測試:新 `test/regression/test_170_subforum_cards_test.dart`,子版塊 tab(真 `ForumPage`,無帖子自動切 tab)與 `ForumGroupPage` 各跑一次:1600 → 兩欄大卡、內容 1520 置中、圖 240×120、名稱 24、三統計;1100@2x 長名稱 → 大卡、捲動無例外;1140 寬左側 inset 44 → 實際 1096 維持精簡卡;844×390@2x 左右 inset 44 → 精簡卡且都在安全區內、捲動無例外;320@2x → 單欄精簡 88×44、捲動無例外;390 與 1600 點卡片進入子版塊。 + +## 手機不用電腦版大小+無帖子自動切 tab 修正(使用者 109 追加與 Codex 實跑結果,2026-09-28)— 程式已改,未驗證 + +- **僅桌面平台放大**:使用者明確要求「手機版不要用電腦版這個大小」。`forum_card.dart` 新增 `forumCardListIsDesktop(TargetPlatform)`(Windows/Linux/macOS 為桌面,Android/iOS/Fuchsia 否);`forumCardListUsesLargeCards(width, platform)`=桌面且量到寬度 ≥ 1100;`forumCardListLayout(width, platform)`。三處(版塊頁、子版塊 tab、gid 分組頁)皆傳 `Theme.of(context).platform`(App 未設 ThemeData.platform,正式環境即實際平台;測試用 `TargetPlatformVariant` 可控)。Android/iOS 即使 1600 寬橫向或平板也維持手機精簡卡、960 共用寬度;寬度仍於 SafeArea 內量測。 +- **strict**:`topics_page.dart` 別名補型別 `const double topicsPageMaxWidth`/`topicsPageLargeCardWidth`;`topicsPageUsesLargeCards` 改為 `(width, platform)`。 +- **生命週期**:`ForumPage` 原在 `BlocBuilder` builder 內呼叫 `tabController.animateTo(子版塊)`,TabController 同步通知 `_updateFabVisibilityByTabIndex` → build 中 `setState`(test_170 子版塊全敗的原因),且每次重建都把 tab 拉回子版塊。改為外層 `BlocListener`:`listenWhen` 為載入成功、無帖子、非篩選;listener 檢查 `mounted` 與目前不在子版塊 tab 才切。只在新狀態觸發,不在 build 中,使用者手動切回主題 tab 不再被拉回。 +- **測試**:test_169 桌面案例(1600、1000 中寬、1100@2x、1100@1x、導航)標 Windows,320@2x 標 Android,新增 Android 1600 寬仍精簡卡。test_170 兩頁各 8 項:Windows 1600 大卡、Android 1600 精簡、Windows 1100@2x、Windows 1140−inset44、Android 844×390@2x 安全區、Android 320@2x、Android 390 導航、Windows 1600 導航;另加「無帖子」群組:Android 390 自動切子版塊 → 點主題 tab 維持在主題 tab(顯示無帖子)→ 切回子版塊點卡片導航;Windows 1600 自動切子版塊且大卡。 + +## Android 預覽 109 回饋(feedback 110,2026-09-28)— 程式已改,修正後的測試尚未重跑,未實機驗收 + +逐項根因、測試與未解事項見 `work/pr135-feedback110/coverage.md`;新測試 `test/regression/test_171_feedback110_test.dart`。需要 slang(紅包文案)與 build_runner(`HomepageState`/新事件)。 + +- **選擇器欄位**:新增共用 `appPickerDecoration(context, label:, icon:)`:填色+圓角無線 `UnderlineInputBorder`(聚焦主色底線),各狀態邊框都明確設定,浮動標籤在填色區內。勳章中心分類、銀行業務改用它;銀行未選值時標籤作 placeholder(`isEmpty`),不再同時顯示同字 hint。 +- **rail**:上下 inset 移到捲動區外的同色帶,捲動只在兩帶之間裁切(`Clip.hardEdge`);左 cutout 仍由 `NavigationRail` 處理。捲動高度改為量測 rail 的 intrinsic 高度(至少一個視窗高),不再用估算值:估算偏大時拖到底會留下空白,最後一項停在捲動區上緣被切掉一半。 +- **SafeArea 分工**:瀏覽紀錄篩選列與列表同一個 SafeArea,篩選列的左右留白直接用列表的 `appCenteredPadding`(同一寬度量測),所以 chip 與卡片起點一致。`ReplyBar` 背景延伸到螢幕邊緣,輸入框自加左右 inset 與底部 `safePadding()`;點開後的編輯器 sheet 本來就不在頁面的 SafeArea 內,這次也讓它的內容與按鈕(含手機工具列面板)避開左右 inset,背景仍到邊。帖子(含軟關閉橫幅)、私訊兩頁、通知詳情只把內容包 SafeArea,錯誤/需登入等狀態仍在 SafeArea 內。`resizeToAvoidBottomInset` 與 chat_bottom_container 的鍵盤處理不變。 +- **作者彈窗第二牌子**:解析改為稱號區塊內任何後代 `img`(例如被連結包住);彈窗樓層無稱號且作者 uid 等於目前已驗證帳號時顯示 `CurrentAccountTitleBadge`(以作者 uid 為 key),他人樓層無資料就不顯示。已驗證的只有解析器與彈窗接線(樣本與合成樓層);測試員截圖的名字被遮住,也沒有那一樓的原始 HTML,所以「截圖是本人樓層」「那一樓沒有稱號區塊」都只是假設,不是已確認的根因。他人樓層在真實頁面上為何沒有第二牌子,仍待測試員提供可重現的樓層。 +- **首頁**:AppBar 的活動/簽到只在 `failure`(沒有問候卡)時出現,載入/刷新不再閃出。問候卡牌子改用 `homeGreetingBadgeLayout`:手機問候至少 148×字級,旁邊空間 ≥ 偏好 160–184 就並排,≥ 144 就用剩餘寬並排,否則放下方(160);桌面 240 不變;手機日期行可兩行。紅包「暫無」改送 `HomepageDailyRedPacketCheckRequested`:不進 loading、只重抓 `forum.php` 並只更新紅包與 formHash,頁面重載/換帳號/文件 uid 不符時丟棄;下拉仍是整頁刷新。紅包文案三語縮短(「紅包:暫無」「紅包已領取」「登入領紅包」),說明留在 tooltip。 +- **通知**:「未讀」改為 AppBar 切換鈕(選取 tonal 底色+實心圖示、tooltip、`toggled` 語意),移除獨佔的篩選列。 +- **設定**:除錯區 `ExpansionTile` 的 `tilePadding` 改為與其他列相同的 16。 +- 手機維持精簡 `ForumCard`、桌面 ≥ 1100 才大卡(test169/170 不變)。 diff --git a/lib/app.dart b/lib/app.dart index 8cce3d499..edf3ce66c 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -32,6 +32,7 @@ import 'package:tsdm_client/features/notification/models/models.dart'; import 'package:tsdm_client/features/notification/repository/notification_info_repository.dart'; import 'package:tsdm_client/features/notification/repository/notification_repository.dart'; import 'package:tsdm_client/features/notification/repository/notification_sync_all_repository.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; import 'package:tsdm_client/features/profile/repository/profile_repository.dart'; import 'package:tsdm_client/features/replied_thread/cubit/replied_thread_cubit.dart'; import 'package:tsdm_client/features/root/bloc/points_changes_cubit.dart'; @@ -59,6 +60,7 @@ import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; import 'package:tsdm_client/utils/tray_helper.dart'; import 'package:tsdm_client/utils/window_events.dart'; +import 'package:tsdm_client/widgets/desktop_back_handler.dart'; import 'package:window_manager/window_manager.dart'; /// Main app for tsdm_client. @@ -345,6 +347,14 @@ class _AppState extends State with WindowListener, WidgetsBindingObserver, }, lazy: false, ), + // Secondary title of the current account for the homepage, the own profile and the medal & title hub; read + // on demand, keyed by account and dropped on every account change. + BlocProvider( + create: (context) { + final auth = context.repo(); + return CurrentTitleCubit(currentUid: () => auth.currentUser?.uid, authStatus: auth.status); + }, + ), // Top level: leaving the progress page must not cancel the run, like the auto checkin. BlocProvider( create: (context) => NotificationSyncAllCubit( @@ -585,7 +595,7 @@ class _AppState extends State with WindowListener, WidgetsBindingObserver, textScaler: TextScaler.linear(textScaleFactor) ..clamp(minScaleFactor: 0.7, maxScaleFactor: 1.5), ), - child: child ?? sizedBoxEmpty, + child: DesktopBackHandler(child: child ?? sizedBoxEmpty), ); }, ); diff --git a/lib/features/achievements/view/achievements_page.dart b/lib/features/achievements/view/achievements_page.dart index cf75d2abb..2e3caabaf 100644 --- a/lib/features/achievements/view/achievements_page.dart +++ b/lib/features/achievements/view/achievements_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fpdart/fpdart.dart' show Left, Right; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/achievements/cubit/achievements_cubit.dart'; import 'package:tsdm_client/features/achievements/models/achievement_page_data.dart'; @@ -14,6 +15,7 @@ import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Current account's achievements as supplied by the forum, without reward actions. @@ -73,48 +75,97 @@ class _AchievementsPageState extends State { if (state.loading) { body = const CenteredCircularIndicator(); } else if (state.needLogin) { - body = Center( - child: TextButton( + body = AppStateView( + icon: Icons.login, + message: tr.loginRequired, + action: FilledButton.tonalIcon( onPressed: () async { await context.pushNamed(ScreenPaths.login); if (mounted) await _cubit.load(); }, - child: Text(tr.loginRequired), + icon: const Icon(Icons.login), + label: Text(context.t.loginPage.login), ), ); } else if (state.failed) { body = buildRetryButton(context, () => unawaited(_cubit.load()), message: context.t.general.failedToLoad); } else { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; body = RefreshIndicator( onRefresh: _cubit.load, - child: ListView( - padding: const EdgeInsets.all(16), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - if (data?.empty ?? false) ...[ - const Padding(padding: EdgeInsets.all(24), child: Icon(Icons.emoji_events_outlined, size: 56)), - Text(tr.empty, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium), - Padding( - padding: const EdgeInsets.all(12), - child: Text(tr.emptyDetail, textAlign: TextAlign.center), + child: AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 12), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (data?.empty ?? false) + AppSurface( + padding: edgeInsetsL16T16R16B16, + child: Column( + children: [ + const AppIconTile(Icons.emoji_events_outlined, size: 64), + sizedBoxW12H12, + Text(tr.empty, textAlign: TextAlign.center, style: textTheme.titleMedium), + sizedBoxW8H8, + Text( + tr.emptyDetail, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ) + else if (data?.recognized ?? false) + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + tr.title, + icon: Icons.emoji_events_outlined, + padding: const EdgeInsets.only(bottom: 4), + ), + Text(tr.sourceNote, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + sizedBoxW12H12, + AppInsetBlock( + padding: edgeInsetsL12T12R12B12, + child: SelectionArea(child: Text(data!.content.isEmpty ? tr.notProvided : data.content)), + ), + ], + ), + ) + else + AppNoticeBanner( + message: data?.message.isNotEmpty ?? false ? data!.message : tr.unsupported, + tone: AppNoticeTone.warning, + selectable: true, + ), + sizedBoxW12H12, + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 20, color: colorScheme.primary), + sizedBoxW8H8, + Expanded(child: Text(tr.browserNotice)), + ], + ), + sizedBoxW8H8, + OutlinedButton.icon( + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(tr.openBrowser), + onPressed: () async => context.dispatchAsUrl(achievementsUrl, external: true), + ), + ], + ), ), - ] else if (data?.recognized ?? false) ...[ - Text(tr.sourceNote), - const SizedBox(height: 16), - SelectionArea(child: Text(data!.content.isEmpty ? tr.notProvided : data.content)), - ] else - Text(data?.message.isNotEmpty ?? false ? data!.message : tr.unsupported), - const SizedBox(height: 16), - Text(tr.browserNotice), - Align( - alignment: Alignment.centerLeft, - child: OutlinedButton.icon( - icon: const Icon(Icons.open_in_browser_outlined), - label: Text(tr.openBrowser), - onPressed: () async => context.dispatchAsUrl(achievementsUrl, external: true), - ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/activities/view/activities_page.dart b/lib/features/activities/view/activities_page.dart index 4cdac2bbc..7eeaf5b49 100644 --- a/lib/features/activities/view/activities_page.dart +++ b/lib/features/activities/view/activities_page.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fpdart/fpdart.dart' show Left, Right; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/activities/models/forum_activity.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:universal_html/html.dart' as uh; @@ -92,22 +94,70 @@ class _ActivitiesPageState extends State { ? buildRetryButton(context, _load, message: context.t.general.failedToLoad) : RefreshIndicator( onRefresh: _load, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(12), - children: [ - Padding(padding: const EdgeInsets.all(12), child: Text(tr.description)), - if (_activities.isEmpty) Padding(padding: const EdgeInsets.all(24), child: Text(tr.empty)), - for (final activity in _activities) - Card( - child: ListTile( - leading: const Icon(Icons.event_outlined), - title: Text(activity.title), - trailing: const Icon(Icons.chevron_right), - onTap: () async => context.dispatchAsUrl(activity.url), + child: AppCenteredList( + builder: (context, padding, width) { + final columns = appColumnsFor(width); + final gap = width < 600 ? appSurfaceGapCompact : appSurfaceGap; + final colorScheme = Theme.of(context).colorScheme; + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: padding.copyWith(top: 12, bottom: 12), + children: [ + AppSurface( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppIconTile(Icons.celebration_outlined, size: 36), + sizedBoxW12H12, + Expanded( + child: Text( + tr.description, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), ), - ), - ], + SizedBox(height: gap), + if (_activities.isEmpty) + AppStateView(icon: Icons.event_busy_outlined, message: tr.empty, scrollable: false), + for (var row = 0; row < appRowCount(_activities.length, columns); row++) + Padding( + padding: EdgeInsets.only(top: row == 0 ? 0 : gap), + child: AppColumnsRow( + row: row, + columns: columns, + count: _activities.length, + gap: gap, + itemBuilder: (context, index) { + final activity = _activities[index]; + return AppSurface( + onTap: () async => context.dispatchAsUrl(activity.url), + child: Row( + children: [ + const AppIconTile(Icons.event_outlined), + sizedBoxW12H12, + Expanded( + child: Text( + activity.title, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + sizedBoxW8H8, + Icon(Icons.chevron_right, color: colorScheme.outline), + ], + ), + ); + }, + ), + ), + ], + ); + }, ), ), ), diff --git a/lib/features/authentication/view/login_page.dart b/lib/features/authentication/view/login_page.dart index 0e42fffcd..0b564cd34 100644 --- a/lib/features/authentication/view/login_page.dart +++ b/lib/features/authentication/view/login_page.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/bloc/authentication_bloc.dart'; import 'package:tsdm_client/features/authentication/widgets/login_form.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// Widest the login form grows. +const _loginFormMaxWidth = 520.0; /// Page of user to login. class LoginPage extends StatefulWidget { @@ -34,32 +37,21 @@ class _LoginPageState extends State { child: BlocListener( listener: (context, state) { if (state.status == AuthenticationStatus.failure) { - final errorText = switch (state.loginException) { - LoginFormHashNotFoundException() => context.t.loginPage.hashValueNotFound, - LoginInvalidFormHashException() => context.t.loginPage.failedToGetFormHash, - LoginMessageNotFoundException() => context.t.loginPage.failedToLoginMessageNodeNotFound, - LoginIncorrectCaptchaException() => context.t.loginPage.loginResultIncorrectCaptcha, - LoginInvalidCredentialException() => context.t.loginPage.loginResultIncorrectUsernameOrPassword, - LoginIncorrectSecurityQuestionException() => context.t.loginPage.loginResultIncorrectQuestionOrAnswer, - LoginAttemptLimitException() => context.t.loginPage.loginResultTooManyLoginAttempts, - LoginUserInfoNotFoundException() => context.t.loginPage.loginFailed, - LoginOtherErrorException() => context.t.loginPage.loginResultOtherErrors, - _ => context.t.general.failedToLoad, - }; - showSnackBar(context: context, message: errorText); + showSnackBar(context: context, message: loginErrorText(context, state.loginException)); } }, - child: Padding( - padding: const EdgeInsets.all(15), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 500, maxWidth: 500), - child: LoginForm( - redirectPath: widget.redirectBackState?.fullPath, - redirectPathParameters: widget.redirectBackState?.pathParameters, - redirectExtra: widget.redirectBackState?.extra, - username: widget.username, - ), + // One scroll view for the whole form: it used to be a list capped at 500 pixels in the middle of the page, + // which left little room with the keyboard up or at large text scales. + child: SafeArea( + top: false, + child: AppCenteredList( + maxWidth: _loginFormMaxWidth, + builder: (context, padding, _) => LoginForm( + padding: padding.copyWith(top: 24, bottom: 24), + redirectPath: widget.redirectBackState?.fullPath, + redirectPathParameters: widget.redirectBackState?.pathParameters, + redirectExtra: widget.redirectBackState?.extra, + username: widget.username, ), ), ), diff --git a/lib/features/authentication/widgets/captcha_image.dart b/lib/features/authentication/widgets/captcha_image.dart index c1e0b85ba..c576317fe 100644 --- a/lib/features/authentication/widgets/captcha_image.dart +++ b/lib/features/authentication/widgets/captcha_image.dart @@ -9,6 +9,7 @@ import 'package:tsdm_client/extensions/fp.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/fallback_picture.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -82,32 +83,39 @@ class _VerityImageState extends State with LoggerMixin { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: () async => reload(), - child: FutureBuilder( - future: f, - builder: (context, snapshot) { - if (snapshot.hasError) { - // Impossible. - final message = t.loginPage.failedToGetCaptcha(err: snapshot.error!); - debug(message); - return Text(message); - } - - if (snapshot.hasData && futureComplete) { - final either = snapshot.data!; - if (either.isLeft()) { - handle(either.unwrapErr()); - return const FallbackPicture(); - } - - final bytes = Uint8List.fromList(snapshot.data!.unwrap().data as List); - debug('fetch login captcha finished, ${f.hashCode}'); - // 130 x 60 -> 110.9 -> 52 - return Image.memory(bytes, height: _renderHeight); - } - return const SizedBox(width: _indicatorBoxWidth, child: CenteredCircularIndicator()); - }, + // Rounded like the fields beside it; a tap reloads the image (pointer cursor on desktop). + return ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async => reload(), + child: FutureBuilder( + future: f, + builder: (context, snapshot) { + if (snapshot.hasError) { + // Impossible. + final message = t.loginPage.failedToGetCaptcha(err: snapshot.error!); + debug(message); + return Text(message); + } + + if (snapshot.hasData && futureComplete) { + final either = snapshot.data!; + if (either.isLeft()) { + handle(either.unwrapErr()); + return const FallbackPicture(); + } + + final bytes = Uint8List.fromList(snapshot.data!.unwrap().data as List); + debug('fetch login captcha finished, ${f.hashCode}'); + // 130 x 60 -> 110.9 -> 52 + return Image.memory(bytes, height: _renderHeight); + } + return const SizedBox(width: _indicatorBoxWidth, child: CenteredCircularIndicator()); + }, + ), + ), ), ); } diff --git a/lib/features/authentication/widgets/login_form.dart b/lib/features/authentication/widgets/login_form.dart index 6e5f06879..9ae119bbc 100644 --- a/lib/features/authentication/widgets/login_form.dart +++ b/lib/features/authentication/widgets/login_form.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/bloc/authentication_bloc.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; @@ -10,15 +11,37 @@ import 'package:tsdm_client/features/authentication/widgets/captcha_image.dart'; import 'package:tsdm_client/features/notification/bloc/auto_notification_cubit.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/debounce_buttons.dart'; // TODO: Fetch login questions dynamically from web server. final _loginQuestions = ['无安全问题', '母亲的名字', '爷爷的名字', '父亲出生的城市', '您其中一位老师的名字', '您个人计算机的型号', '您最喜欢的餐馆名称', '驾驶执照的最后四位数字']; +/// Text telling why a login failed, for the [exception] the authentication bloc reports. +String loginErrorText(BuildContext context, Object? exception) => switch (exception) { + LoginFormHashNotFoundException() => context.t.loginPage.hashValueNotFound, + LoginInvalidFormHashException() => context.t.loginPage.failedToGetFormHash, + LoginMessageNotFoundException() => context.t.loginPage.failedToLoginMessageNodeNotFound, + LoginIncorrectCaptchaException() => context.t.loginPage.loginResultIncorrectCaptcha, + LoginInvalidCredentialException() => context.t.loginPage.loginResultIncorrectUsernameOrPassword, + LoginIncorrectSecurityQuestionException() => context.t.loginPage.loginResultIncorrectQuestionOrAnswer, + LoginAttemptLimitException() => context.t.loginPage.loginResultTooManyLoginAttempts, + LoginUserInfoNotFoundException() => context.t.loginPage.loginFailed, + LoginOtherErrorException() => context.t.loginPage.loginResultOtherErrors, + _ => context.t.general.failedToLoad, +}; + /// Form for user to fill login info. class LoginForm extends StatefulWidget { /// Constructor. - const LoginForm({this.redirectPath, this.redirectPathParameters, this.redirectExtra, this.username, super.key}); + const LoginForm({ + this.redirectPath, + this.redirectPathParameters, + this.redirectExtra, + this.username, + this.padding, + super.key, + }); /// The url path to redirect back once login succeed. final String? redirectPath; @@ -32,6 +55,9 @@ class LoginForm extends StatefulWidget { /// Optional autofilled username. final String? username; + /// Padding of the scroll view of the form, centers it on wide windows. + final EdgeInsetsGeometry? padding; + @override State createState() => _LoginFormState(); } @@ -87,144 +113,191 @@ class _LoginFormState extends State with LoggerMixin { context.read().add(AuthenticationLoginRequested(credential)); } + /// Icon and title above the fields. + Widget _buildHead(BuildContext context) { + final tr = context.t.loginPage; + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + const AppIconTile(Icons.lock_person_outlined, size: 56), + sizedBoxW12H12, + Text( + tr.title, + textAlign: TextAlign.center, + style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ); + } + + /// How the account is identified: chips that wrap on narrow screens, instead of a drop down squeezed into the + /// prefix of the field. + Widget _buildLoginFieldChips(BuildContext context) { + final tr = context.t.loginPage; + return Wrap( + spacing: 8, + runSpacing: 4, + children: [ + for (final (field, label, icon) in [ + (LoginField.username, tr.loginField.username, Icons.person_outline), + (LoginField.uid, tr.loginField.uid, Icons.tag), + (LoginField.email, tr.loginField.email, Icons.alternate_email_outlined), + ]) + ChoiceChip( + avatar: Icon(icon, size: 18), + label: Text(label), + selected: loginField == field, + showCheckmark: false, + onSelected: (_) { + setState(() => loginField = field); + loginFieldFocus.requestFocus(); + }, + ), + ], + ); + } + Widget _buildForm(BuildContext context, AuthenticationState state) { // Only allow to press login button when got hash but not logged in. final pending = state.status != AuthenticationStatus.gotHash && state.status != AuthenticationStatus.failure; final tr = context.t.loginPage; + // Not a lazy list: every field and the login / retry button stay built, so `validate` checks all the fields + // (a lazy list skips the ones scrolled away) and the retry button can be reached at any text scale. return Form( key: formKey, - child: ListView( - children: [ - Center(child: Text(tr.login, style: Theme.of(context).textTheme.titleLarge)), - sizedBoxW12H12, - TextFormField( - autofocus: widget.username == null, - focusNode: loginFieldFocus, - controller: usernameController, - decoration: InputDecoration( - // prefixIcon: const Icon(Icons.person), - labelText: switch (loginField) { - LoginField.username => tr.loginField.username, - LoginField.uid => tr.loginField.uid, - LoginField.email => tr.loginField.email, - }, - // Follow M3 spec: - // https://m3.material.io/components/text-fields/specs - constraints: const BoxConstraints(maxHeight: specTextFieldHeight), - prefix: DropdownButtonHideUnderline( - child: DropdownButton( - value: loginField, - onChanged: (v) { - if (v == null) { - return; - } - setState(() { - loginField = v; - }); - loginFieldFocus.requestFocus(); - }, - items: [ - DropdownMenuItem(value: LoginField.username, child: Text(tr.loginField.username)), - DropdownMenuItem(value: LoginField.uid, child: Text(tr.loginField.uid)), - DropdownMenuItem(value: LoginField.email, child: Text(tr.loginField.email)), - ], + child: SingleChildScrollView( + padding: widget.padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHead(context), + const SizedBox(height: 16), + AppFormSection( + title: context.t.settingsPage.accountSection.title, + icon: Icons.account_circle_outlined, + children: [ + _buildLoginFieldChips(context), + TextFormField( + autofocus: widget.username == null, + focusNode: loginFieldFocus, + controller: usernameController, + decoration: appFieldDecoration( + label: switch (loginField) { + LoginField.username => tr.loginField.username, + LoginField.uid => tr.loginField.uid, + LoginField.email => tr.loginField.email, + }, + icon: switch (loginField) { + LoginField.username => Icons.person_outline, + LoginField.uid => Icons.tag, + LoginField.email => Icons.alternate_email_outlined, + }, + ), + validator: (v) => v!.trim().isNotEmpty ? null : tr.usernameEmpty, ), - ), - ), - validator: (v) => v!.trim().isNotEmpty ? null : tr.usernameEmpty, - ), - sizedBoxW12H12, - TextFormField( - controller: passwordController, - focusNode: passwordFieldFocus, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.password), - labelText: tr.password, - suffixIcon: Focus( - canRequestFocus: false, - descendantsAreFocusable: false, - child: IconButton( - icon: _showPassword ? const Icon(Icons.visibility) : const Icon(Icons.visibility_off), - onPressed: () { - setState(() { - _showPassword = !_showPassword; - }); - }, + TextFormField( + controller: passwordController, + focusNode: passwordFieldFocus, + decoration: appFieldDecoration( + label: tr.password, + icon: Icons.password, + suffix: Focus( + canRequestFocus: false, + descendantsAreFocusable: false, + child: IconButton( + icon: _showPassword ? const Icon(Icons.visibility) : const Icon(Icons.visibility_off), + onPressed: () { + setState(() { + _showPassword = !_showPassword; + }); + }, + ), + ), + ), + obscureText: !_showPassword, + validator: (v) => v!.trim().isNotEmpty ? null : tr.passwordEmpty, ), - ), + // Captcha is only required when the server says so. + if (state.loginHash?.needCaptcha ?? false) + Row( + children: [ + Expanded( + child: TextFormField( + controller: verifyCodeController, + decoration: appFieldDecoration(label: tr.verifyCode, icon: Icons.pin), + validator: (v) => v!.trim().isNotEmpty ? null : tr.verifyCodeEmpty, + ), + ), + sizedBoxW12H12, + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: CaptchaImage(captchaImageController), + ), + ], + ), + ], ), - obscureText: !_showPassword, - validator: (v) => v!.trim().isNotEmpty ? null : tr.passwordEmpty, - ), - // Captcha is only required when the server says so. - if (state.loginHash?.needCaptcha ?? false) ...[ - sizedBoxW12H12, - Row( + const SizedBox(height: appSurfaceGap), + AppFormSection( + title: tr.securityQuestion, + icon: Icons.shield_outlined, children: [ - Expanded( - child: TextFormField( - controller: verifyCodeController, - decoration: InputDecoration(prefixIcon: const Icon(Icons.pin), labelText: tr.verifyCode), - validator: (v) => v!.trim().isNotEmpty ? null : tr.verifyCodeEmpty, + InputDecorator( + decoration: appFieldDecoration(label: tr.securityQuestion, icon: Icons.question_mark_outlined), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _question, + isDense: true, + // Long questions are cut with an ellipsis instead of overflowing on narrow phones. + isExpanded: true, + borderRadius: BorderRadius.circular(appInnerRadius), + onChanged: (newValue) { + if (newValue == null) { + return; + } + setState(() { + _question = newValue; + }); + }, + items: _loginQuestions.map((value) { + return DropdownMenuItem( + value: value, + child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis), + ); + }).toList(), + ), ), ), - sizedBoxW12H12, - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: CaptchaImage(captchaImageController), + TextFormField( + controller: answerController, + decoration: appFieldDecoration( + label: tr.answer, + icon: Icons.question_answer_outlined, + ).copyWith(enabled: _question != _loginQuestions.first), + validator: (v) => _question == _loginQuestions.first || v!.trim().isNotEmpty ? null : tr.answerEmpty, ), ], ), - ], - sizedBoxW12H12, - InputDecorator( - decoration: InputDecoration( - prefixIcon: const Icon(Icons.question_mark_outlined), - labelText: tr.securityQuestion, + if (state.status == AuthenticationStatus.failure) ...[ + const SizedBox(height: appSurfaceGap), + AppNoticeBanner(message: loginErrorText(context, state.loginException), tone: AppNoticeTone.error), + ], + const SizedBox(height: 16), + DebounceFilledButton( + shouldDebounce: pending, + onPressed: () async => _login(context, loginField, state), + child: Text(state.status == AuthenticationStatus.failure ? context.t.general.retry : tr.login), ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: _question, - isDense: true, - onChanged: (newValue) { - if (newValue == null) { - return; - } - setState(() { - _question = newValue; - }); - }, - items: _loginQuestions.map((value) { - return DropdownMenuItem(value: value, child: Text(value)); - }).toList(), + sizedBoxW12H12, + Center( + child: TextButton( + child: Text(tr.signUpHint, style: const TextStyle(decoration: TextDecoration.underline)), + onPressed: () async => context.dispatchAsUrl(signUpPage, external: true), ), ), - ), - sizedBoxW12H12, - TextFormField( - controller: answerController, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.question_answer_outlined), - labelText: tr.answer, - enabled: _question != _loginQuestions.first, - ), - validator: (v) => _question == _loginQuestions.first || v!.trim().isNotEmpty ? null : tr.answerEmpty, - ), - sizedBoxW12H12, - DebounceFilledButton( - shouldDebounce: pending, - onPressed: () async => _login(context, loginField, state), - child: Text(state.status == AuthenticationStatus.failure ? context.t.general.retry : tr.login), - ), - sizedBoxW12H12, - Center( - child: TextButton( - child: Text(tr.signUpHint, style: const TextStyle(decoration: TextDecoration.underline)), - onPressed: () async => context.dispatchAsUrl(signUpPage, external: true), - ), - ), - ], + ], + ), ), ); } diff --git a/lib/features/bank/view/bank_page.dart b/lib/features/bank/view/bank_page.dart index 2fef34f51..1d503f9db 100644 --- a/lib/features/bank/view/bank_page.dart +++ b/lib/features/bank/view/bank_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; @@ -14,6 +15,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; part 'bank_service_widgets.dart'; @@ -128,17 +130,20 @@ class _BankPageState extends State { Widget _services(BankState state, {required bool disabled}) { final bank = state.bank; + final value = state.service?.name ?? ((bank?.hasAccount ?? false) ? 'current' : null); return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.only(bottom: appSurfaceGap), child: InputDecorator( - decoration: InputDecoration(labelText: context.t.bank.services, border: const OutlineInputBorder()), + // Nothing picked: the label rests in the field as its placeholder instead of floating above a duplicate hint. + isEmpty: value == null, + decoration: appPickerDecoration(context, label: context.t.bank.services, icon: Icons.apps_outlined), child: DropdownButtonHideUnderline( child: DropdownButton( key: const ValueKey('bank-service-picker'), isExpanded: true, isDense: true, - value: state.service?.name ?? ((bank?.hasAccount ?? false) ? 'current' : null), - hint: Text(context.t.bank.services), + borderRadius: BorderRadius.circular(appInnerRadius), + value: value, onChanged: disabled ? null : (value) { @@ -168,87 +173,130 @@ class _BankPageState extends State { ); } + Widget _bankTile(ForumBank item, {required bool disabled}) { + final tr = context.t.bank; + final colorScheme = Theme.of(context).colorScheme; + return AppSurface( + padding: EdgeInsets.zero, + child: ListTile( + key: ValueKey('bank-${item.id}'), + contentPadding: const EdgeInsets.fromLTRB(12, 4, 12, 4), + leading: const AppIconTile(Icons.account_balance_outlined), + title: Text(item.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.description.isNotEmpty) Text(item.description), + sizedBoxW4H4, + AppInfoPill( + icon: item.hasAccount ? Icons.check_circle_outline : Icons.remove_circle_outline, + label: item.hasAccount ? tr.opened : tr.notOpened, + ), + ], + ), + trailing: Icon(Icons.chevron_right, color: colorScheme.outline), + onTap: disabled ? null : () => _cubit.selectBank(item), + ), + ); + } + Widget _serviceContent(BankState state, {required bool disabled}) { final tr = context.t.bank; final data = state.serviceData; + final textTheme = Theme.of(context).textTheme; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: disabled ? null : _cubit.load, - icon: const Icon(Icons.arrow_back), - label: Text(tr.backToBanks), - ), - ), - Text( - [ + _BankHeader( + icon: _serviceIcon(state.service!), + title: [ if (!state.service!.global && state.bank != null) state.bank!.name, _serviceTitle(context, state.service!), ].join(' · '), - style: Theme.of(context).textTheme.titleLarge, + onBack: disabled ? null : _cubit.load, + backLabel: tr.backToBanks, ), if (data != null) ...[ - if (data.unavailable.isNotEmpty) Padding(padding: const EdgeInsets.all(16), child: Text(data.unavailable)), - if (data.walletBalance.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text('${tr.availableBalance}: ${data.walletBalance} ${data.currency}'), + if (data.unavailable.isNotEmpty) ...[ + sizedBoxW12H12, + AppNoticeBanner(message: data.unavailable, tone: AppNoticeTone.warning, selectable: true), + ], + if (data.walletBalance.isNotEmpty) ...[ + sizedBoxW12H12, + _BankValue( + icon: Icons.account_balance_wallet_outlined, + label: tr.availableBalance, + value: '${data.walletBalance} ${data.currency}'.trim(), ), - for (final form in data.forms) - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (form.context.isNotEmpty) - Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(form.context)), - FilledButton( - onPressed: disabled ? null : () => _serviceTransaction(data, form), - child: Text(_serviceActionTitle(context, form)), - ), + ], + for (final form in data.forms) ...[ + sizedBoxW12H12, + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (form.context.isNotEmpty) ...[ + AppInsetBlock(child: Text(form.context)), + sizedBoxW12H12, ], - ), + FilledButton( + onPressed: disabled ? null : () => _serviceTransaction(data, form), + child: Text(_serviceActionTitle(context, form), textAlign: TextAlign.center), + ), + ], ), ), - for (final block in data.blocks) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Text(block.text, style: block.heading ? Theme.of(context).textTheme.titleMedium : null), + ], + if (data.blocks.isNotEmpty) ...[ + sizedBoxW12H12, + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (index, block) in data.blocks.indexed) + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 8), + child: Text( + block.text, + style: block.heading ? textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold) : null, + ), + ), + ], + ), ), - if (data.unsupportedForms) Padding(padding: const EdgeInsets.all(12), child: Text(tr.unsupported)), - if (data.hasNext || state.servicePage > 1) - Wrap( - spacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - TextButton( - onPressed: disabled || state.servicePage <= 1 - ? null - : () => _cubit.loadService(state.service!, page: state.servicePage - 1), - child: Text(tr.previous), - ), - Text('${state.servicePage}'), - TextButton( - onPressed: disabled || !data.hasNext - ? null - : () => _cubit.loadService(state.service!, page: state.servicePage + 1), - child: Text(tr.next), - ), - ], + ], + if (data.unsupportedForms) ...[ + sizedBoxW12H12, + AppNoticeBanner(message: tr.unsupported, tone: AppNoticeTone.warning), + ], + if (data.hasNext || state.servicePage > 1) ...[ + sizedBoxW8H8, + _BankPager( + page: state.servicePage, + previousLabel: tr.previous, + nextLabel: tr.next, + onPrevious: disabled || state.servicePage <= 1 + ? null + : () => _cubit.loadService(state.service!, page: state.servicePage - 1), + onNext: disabled || !data.hasNext + ? null + : () => _cubit.loadService(state.service!, page: state.servicePage + 1), ), + ], ], - TextButton.icon( - onPressed: disabled - ? null - : () async => context.dispatchAsUrl( - bankServiceUrl(state.service!, bankId: state.bank?.id, page: state.servicePage), - external: true, - ), - icon: const Icon(Icons.open_in_browser_outlined), - label: Text(tr.openWebsite), + sizedBoxW8H8, + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: disabled + ? null + : () async => context.dispatchAsUrl( + bankServiceUrl(state.service!, bankId: state.bank?.id, page: state.servicePage), + external: true, + ), + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(tr.openWebsite), + ), ), ], ); @@ -256,60 +304,67 @@ class _BankPageState extends State { Widget _savings(ForumBank bank, BankSavings savings, {required bool disabled}) { final tr = context.t.bank; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(tr.savings, style: Theme.of(context).textTheme.titleMedium), - if (savings.summary.isNotEmpty) ...[ - const SizedBox(height: 12), - Text(savings.summary), - ], - if (savings.walletBalance.isNotEmpty) ...[ - const SizedBox(height: 12), - Text('${tr.availableBalance}: ${savings.walletBalance} ${savings.currency}'.trim()), - ], - if (savings.interest.isNotEmpty) ...[ - const SizedBox(height: 12), - Text('${tr.interest}: ${savings.interest}'), - ], - if (savings.notices.isNotEmpty) ...[ - const SizedBox(height: 12), - Text(savings.notices), - ], - const SizedBox(height: 16), - if (savings.form != null) - Wrap( - spacing: 12, - runSpacing: 8, - children: [ - FilledButton.icon( - key: const ValueKey('bank-deposit'), - onPressed: disabled ? null : () => _transact(bank, savings, BankOperation.deposit), - icon: const Icon(Icons.savings_outlined), - label: Text(tr.deposit), - ), - OutlinedButton.icon( - key: const ValueKey('bank-withdraw'), - onPressed: disabled ? null : () => _transact(bank, savings, BankOperation.withdraw), - icon: const Icon(Icons.account_balance_wallet_outlined), - label: Text(tr.withdraw), + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader(tr.savings, icon: Icons.savings_outlined, padding: const EdgeInsets.only(bottom: 8)), + if (savings.summary.isNotEmpty) AppInsetBlock(child: Text(savings.summary)), + if (savings.walletBalance.isNotEmpty || savings.interest.isNotEmpty) ...[ + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (savings.walletBalance.isNotEmpty) + _BankValue( + icon: Icons.account_balance_wallet_outlined, + label: tr.availableBalance, + value: '${savings.walletBalance} ${savings.currency}'.trim(), ), - ], - ) - else - Text(tr.unsupported), - TextButton.icon( + if (savings.interest.isNotEmpty) + _BankValue(icon: Icons.trending_up_outlined, label: tr.interest, value: savings.interest), + ], + ), + ], + if (savings.notices.isNotEmpty) ...[ + sizedBoxW8H8, + AppNoticeBanner(message: savings.notices, selectable: true), + ], + sizedBoxW12H12, + if (savings.form != null) + Wrap( + spacing: 12, + runSpacing: 8, + children: [ + FilledButton.icon( + key: const ValueKey('bank-deposit'), + onPressed: disabled ? null : () => _transact(bank, savings, BankOperation.deposit), + icon: const Icon(Icons.savings_outlined), + label: Text(tr.deposit), + ), + OutlinedButton.icon( + key: const ValueKey('bank-withdraw'), + onPressed: disabled ? null : () => _transact(bank, savings, BankOperation.withdraw), + icon: const Icon(Icons.account_balance_wallet_outlined), + label: Text(tr.withdraw), + ), + ], + ) + else + AppNoticeBanner(message: tr.unsupported, tone: AppNoticeTone.warning), + sizedBoxW4H4, + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( onPressed: disabled ? null : () async => context.dispatchAsUrl(bankPageUrl(bankId: bank.id, action: 'cur'), external: true), icon: const Icon(Icons.open_in_browser_outlined), label: Text(tr.openWebsite), ), - ], - ), + ), + ], ), ); } @@ -317,62 +372,78 @@ class _BankPageState extends State { Widget _logs(BankState state, {required bool disabled}) { final tr = context.t.bank; final logs = state.logs; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(tr.logs, style: Theme.of(context).textTheme.titleMedium), - if (state.logsFailed) Text(tr.logsFailed, style: TextStyle(color: Theme.of(context).colorScheme.error)), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ChoiceChip( - label: Text(tr.ownLogs), - selected: logs != null && !state.received, - onSelected: disabled ? null : (_) => unawaited(_cubit.loadLogs()), - ), - ChoiceChip( - label: Text(tr.receivedLogs), - selected: logs != null && state.received, - onSelected: disabled ? null : (_) => unawaited(_cubit.loadLogs(received: true)), - ), - ], - ), - if (logs != null) ...[ - const SizedBox(height: 12), - if (logs.entries.isEmpty) Text(tr.emptyLogs), - for (final entry in logs.entries) - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(entry.message), - subtitle: entry.time.isEmpty ? null : Text(entry.time), - ), - Wrap( - spacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - TextButton( - onPressed: disabled || state.logPage <= 1 - ? null - : () => _cubit.loadLogs(received: state.received, page: state.logPage - 1), - child: Text(tr.previous), - ), - Text('${state.logPage}'), - TextButton( - onPressed: disabled || !logs.hasNext - ? null - : () => _cubit.loadLogs(received: state.received, page: state.logPage + 1), - child: Text(tr.next), - ), - ], + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader(tr.logs, icon: Icons.receipt_long_outlined, padding: const EdgeInsets.only(bottom: 8)), + if (state.logsFailed) ...[ + AppNoticeBanner(message: tr.logsFailed, tone: AppNoticeTone.error), + sizedBoxW8H8, + ], + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ChoiceChip( + label: Text(tr.ownLogs), + selected: logs != null && !state.received, + onSelected: disabled ? null : (_) => unawaited(_cubit.loadLogs()), + ), + ChoiceChip( + label: Text(tr.receivedLogs), + selected: logs != null && state.received, + onSelected: disabled ? null : (_) => unawaited(_cubit.loadLogs(received: true)), ), ], + ), + if (logs != null) ...[ + sizedBoxW12H12, + if (logs.entries.isEmpty) + AppStateView(icon: Icons.receipt_long_outlined, message: tr.emptyLogs, scrollable: false), + for (final (index, entry) in logs.entries.indexed) + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 6), + child: AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.message), + if (entry.time.isNotEmpty) ...[ + sizedBoxW4H4, + Row( + children: [ + Icon(Icons.schedule_outlined, size: 14, color: colorScheme.outline), + sizedBoxW4H4, + Flexible( + child: Text( + entry.time, + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ], + ], + ), + ), + ), + sizedBoxW8H8, + _BankPager( + page: state.logPage, + previousLabel: tr.previous, + nextLabel: tr.next, + onPrevious: disabled || state.logPage <= 1 + ? null + : () => _cubit.loadLogs(received: state.received, page: state.logPage - 1), + onNext: disabled || !logs.hasNext + ? null + : () => _cubit.loadLogs(received: state.received, page: state.logPage + 1), + ), ], - ), + ], ), ); } @@ -422,87 +493,114 @@ class _BankPageState extends State { onRefresh: () async { if (!disabled) await _cubit.refresh(); }, - child: ListView( - padding: const EdgeInsets.all(12), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - if (state.busy) const LinearProgressIndicator(), - if (state.unconfirmed) - Card( - child: Padding(padding: const EdgeInsets.all(16), child: Text(tr.unconfirmed)), - ), - if (state.loginRequired) - TextButton( - onPressed: disabled - ? null - : () async { - await context.pushNamed(ScreenPaths.login); - if (mounted) await _cubit.load(); - }, - child: Text(tr.loginRequired), - ) - else ...[ - if (state.uid != null) _services(state, disabled: disabled), - if (state.failed) - Padding( - padding: const EdgeInsets.all(16), - child: Text(tr.loadFailed, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ), - if (state.service != null) - _serviceContent(state, disabled: disabled) - else if (bank == null) ...[ - Padding( - padding: const EdgeInsets.all(12), - child: Text(tr.chooseBank, style: Theme.of(context).textTheme.titleMedium), - ), - for (final item in state.banks) - Card( - child: ListTile( - key: ValueKey('bank-${item.id}'), - leading: const Icon(Icons.account_balance_outlined), - title: Text(item.name), - subtitle: Text( - [ - if (item.description.isNotEmpty) item.description, - if (item.hasAccount) tr.opened else tr.notOpened, - ].join('\n'), - ), - trailing: const Icon(Icons.chevron_right), - onTap: disabled ? null : () => _cubit.selectBank(item), - ), + child: AppCenteredList( + builder: (context, padding, width) { + final columns = appColumnsFor(width); + final gap = width < 600 ? appSurfaceGapCompact : appSurfaceGap; + final banks = state.banks; + return ListView( + padding: padding.copyWith(top: 12, bottom: 12), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (state.busy) + const Padding( + padding: EdgeInsets.only(bottom: appSurfaceGapCompact), + child: LinearProgressIndicator(), ), - ] else ...[ - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: disabled ? null : _cubit.load, - icon: const Icon(Icons.arrow_back), - label: Text(tr.backToBanks), + if (state.unconfirmed) + Padding( + padding: const EdgeInsets.only(bottom: appSurfaceGap), + child: AppNoticeBanner( + message: tr.unconfirmed, + tone: AppNoticeTone.warning, + icon: Icons.help_outline, + ), ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: Text(bank.name, style: Theme.of(context).textTheme.titleLarge), - ), - if (!bank.hasAccount) - Padding(padding: const EdgeInsets.all(12), child: Text(tr.notOpened)) + if (state.loginRequired) + AppStateView( + icon: Icons.login, + message: tr.loginRequired, + scrollable: false, + action: FilledButton.tonalIcon( + onPressed: disabled + ? null + : () async { + await context.pushNamed(ScreenPaths.login); + if (mounted) await _cubit.load(); + }, + icon: const Icon(Icons.login), + label: Text(context.t.loginPage.login), + ), + ) else ...[ - if (savings != null) _savings(bank, savings, disabled: disabled), - _logs(state, disabled: disabled), + if (state.uid != null) _services(state, disabled: disabled), + if (state.failed) + Padding( + padding: const EdgeInsets.only(bottom: appSurfaceGap), + child: AppNoticeBanner(message: tr.loadFailed, tone: AppNoticeTone.error), + ), + if (state.service != null) + _serviceContent(state, disabled: disabled) + else if (bank == null) ...[ + AppSectionHeader(tr.chooseBank, icon: Icons.account_balance_outlined), + for (var row = 0; row < appRowCount(banks.length, columns); row++) + Padding( + padding: EdgeInsets.only(top: row == 0 ? 0 : gap), + child: AppColumnsRow( + row: row, + columns: columns, + count: banks.length, + gap: gap, + itemBuilder: (context, index) => _bankTile(banks[index], disabled: disabled), + ), + ), + ] else ...[ + _BankHeader( + icon: Icons.account_balance_outlined, + title: bank.name, + onBack: disabled ? null : _cubit.load, + backLabel: tr.backToBanks, + ), + sizedBoxW12H12, + if (!bank.hasAccount) + AppNoticeBanner(message: tr.notOpened, icon: Icons.no_accounts_outlined) + else if (columns > 1) + // Savings and records side by side on wide windows. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: savings == null ? sizedBoxEmpty : _savings(bank, savings, disabled: disabled), + ), + SizedBox(width: gap), + Expanded(child: _logs(state, disabled: disabled)), + ], + ) + else ...[ + if (savings != null) ...[ + _savings(bank, savings, disabled: disabled), + SizedBox(height: gap), + ], + _logs(state, disabled: disabled), + ], + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + onPressed: disabled + ? null + : () async => context.dispatchAsUrl(bankPageUrl(bankId: bank.id), external: true), + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(tr.otherServices), + ), + ), + ), + ], ], - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: OutlinedButton.icon( - onPressed: disabled - ? null - : () async => context.dispatchAsUrl(bankPageUrl(bankId: bank.id), external: true), - icon: const Icon(Icons.open_in_browser_outlined), - label: Text(tr.otherServices), - ), - ), ], - ], - ], + ); + }, ), ), ), @@ -585,26 +683,56 @@ class _BankTransactionDialogState extends State<_BankTransactionDialog> { }, child: AlertDialog( scrollable: true, - title: Text(_confirming ? tr.confirmTitle : operation), + title: _BankDialogTitle( + icon: _confirming + ? Icons.fact_check_outlined + : widget.operation == BankOperation.deposit + ? Icons.savings_outlined + : Icons.account_balance_wallet_outlined, + title: _confirming ? tr.confirmTitle : operation, + ), content: _confirming ? Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Text(widget.bankName, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - Text('$operation: ${_amount.text.trim()} ${widget.savings.currency}'.trim()), - const SizedBox(height: 16), - Text(tr.transactionNote), + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.bankName, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + sizedBoxW8H8, + Text( + '$operation: ${_amount.text.trim()} ${widget.savings.currency}'.trim(), + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + ), + sizedBoxW12H12, + AppNoticeBanner(message: tr.transactionNote, tone: AppNoticeTone.warning, icon: Icons.info_outline), ], ) : Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(widget.bankName), + Row( + children: [ + Icon(Icons.account_balance_outlined, size: 18, color: Theme.of(context).colorScheme.outline), + sizedBoxW8H8, + Expanded(child: Text(widget.bankName)), + ], + ), const SizedBox(height: 16), TextFormField( key: const ValueKey('bank-amount'), @@ -619,7 +747,13 @@ class _BankTransactionDialogState extends State<_BankTransactionDialog> { (previous, next) => RegExp(r'^[0-9]{0,18}$').hasMatch(next.text) ? next : previous, ), ], - decoration: InputDecoration(labelText: tr.amount, suffixText: widget.savings.currency), + decoration: InputDecoration( + labelText: tr.amount, + suffixText: widget.savings.currency, + prefixIcon: const Icon(Icons.payments_outlined), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), validator: (value) => widget.savings.form?.accepts(value?.trim() ?? '') ?? false ? null : tr.invalidAmount, ), @@ -631,12 +765,22 @@ class _BankTransactionDialogState extends State<_BankTransactionDialog> { enableSuggestions: false, autocorrect: false, textInputAction: TextInputAction.done, - decoration: InputDecoration(labelText: tr.bankPassword), + decoration: InputDecoration( + labelText: tr.bankPassword, + prefixIcon: const Icon(Icons.lock_outline), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), validator: (value) => value == null || value.isEmpty ? tr.passwordRequired : null, onFieldSubmitted: (_) => _continue(), ), const SizedBox(height: 8), - Text(tr.passwordHint, style: Theme.of(context).textTheme.bodySmall), + Text( + tr.passwordHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ], ), ), diff --git a/lib/features/bank/view/bank_service_widgets.dart b/lib/features/bank/view/bank_service_widgets.dart index ccb31d996..1f96b470f 100644 --- a/lib/features/bank/view/bank_service_widgets.dart +++ b/lib/features/bank/view/bank_service_widgets.dart @@ -15,6 +15,156 @@ String _serviceTitle(BuildContext context, BankService service) { }; } +IconData _serviceIcon(BankService service) => switch (service) { + BankService.hall => Icons.store_mall_directory_outlined, + BankService.term => Icons.lock_clock_outlined, + BankService.remittance => Icons.send_outlined, + BankService.loans => Icons.request_quote_outlined, + BankService.account => Icons.manage_accounts_outlined, + BankService.interest => Icons.trending_up_outlined, + BankService.overview => Icons.summarize_outlined, + BankService.ranking => Icons.leaderboard_outlined, + BankService.exchange => Icons.currency_exchange_outlined, +}; + +/// Back to the bank list, then the icon and name of the bank or service shown. +class _BankHeader extends StatelessWidget { + const _BankHeader({required this.icon, required this.title, required this.onBack, required this.backLabel}); + + final IconData icon; + final String title; + final VoidCallback? onBack; + final String backLabel; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon(onPressed: onBack, icon: const Icon(Icons.arrow_back), label: Text(backLabel)), + sizedBoxW4H4, + Row( + children: [ + AppIconTile(icon, size: 44), + sizedBoxW12H12, + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ], + ); +} + +/// A labelled amount (balance, interest): caption above, the value in bold, never shortened. +class _BankValue extends StatelessWidget { + const _BankValue({required this.icon, required this.label, required this.value}); + + final IconData icon; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return AppInsetBlock( + color: colorScheme.surfaceContainerHigh, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + sizedBoxW8H8, + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + Text(value, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + ], + ), + ); + } +} + +/// Previous / page / next of the bank records and service pages; the total is not known. +class _BankPager extends StatelessWidget { + const _BankPager({ + required this.page, + required this.previousLabel, + required this.nextLabel, + required this.onPrevious, + required this.onNext, + }); + + final int page; + final String previousLabel; + final String nextLabel; + final VoidCallback? onPrevious; + final VoidCallback? onNext; + + @override + Widget build(BuildContext context) => Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + TextButton( + onPressed: onPrevious, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [const Icon(Icons.chevron_left, size: 18), sizedBoxW4H4, Text(previousLabel)], + ), + ), + AppInsetBlock( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text('$page'), + ), + TextButton( + onPressed: onNext, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text(nextLabel), sizedBoxW4H4, const Icon(Icons.chevron_right, size: 18)], + ), + ), + ], + ); +} + +/// Icon tile and title of the bank dialogs; the title wraps instead of being cut. +class _BankDialogTitle extends StatelessWidget { + const _BankDialogTitle({required this.icon, required this.title}); + + final IconData icon; + final String title; + + @override + Widget build(BuildContext context) => Row( + children: [ + AppIconTile(icon, size: 36), + sizedBoxW12H12, + Expanded(child: Text(title)), + ], + ); +} + +/// Filled, rounded decoration of the bank form fields. +InputDecoration _bankFieldDecoration(String label, {String? helperText}) => InputDecoration( + labelText: label, + helperText: helperText, + helperMaxLines: 3, + errorMaxLines: 3, + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), +); + String _serviceActionTitle(BuildContext context, BankServiceForm form) { final tr = context.t.bank; return switch (form.service) { @@ -108,42 +258,92 @@ class _BankServiceDialogState extends State<_BankServiceDialog> { if (!widget.controller.isCurrentService(widget.data, widget.form)) _cancel(); }, child: AlertDialog( - title: Text(_confirming ? tr.confirmTitle : title), + title: _BankDialogTitle( + icon: _confirming ? Icons.fact_check_outlined : _serviceIcon(widget.form.service), + title: _confirming ? tr.confirmTitle : title, + ), content: SizedBox( width: 420, child: SingleChildScrollView( child: !current - ? Text(tr.confirmExpired) + ? AppNoticeBanner(message: tr.confirmExpired, tone: AppNoticeTone.warning) : Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('${widget.bankName} · $title'), - if (widget.data.currency.isNotEmpty) Text(widget.data.currency), + AppInsetBlock( + outlined: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.bankName} · $title', + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + if (widget.data.currency.isNotEmpty) + Text( + widget.data.currency, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), if (widget.form.context.isNotEmpty) - Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(widget.form.context)), + Padding(padding: const EdgeInsets.only(top: 8), child: Text(widget.form.context)), + sizedBoxW8H8, if (_confirming) ...[ - for (final entry in _inputs.entries.where((entry) => !entry.key.secret)) - Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Text('${_inputTitle(context, entry.key)}: ${entry.value.text}'), + AppInsetBlock( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final entry in _inputs.entries.where((entry) => !entry.key.secret)) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + '${_inputTitle(context, entry.key)}: ${entry.value.text}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + if (widget.data.blocks.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(widget.data.blocks.first.text), + ), + if (widget.data.blocks.length > 1) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(widget.data.blocks.last.text), + ), + ], ), - if (widget.data.blocks.isNotEmpty) Text(widget.data.blocks.first.text), - if (widget.data.blocks.length > 1) Text(widget.data.blocks.last.text), + ), if (widget.form.service == BankService.account && widget.form.operation == 'cl') - Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr.closeWarning)), - Padding(padding: const EdgeInsets.only(top: 12), child: Text(tr.serviceTransactionNote)), + Padding( + padding: const EdgeInsets.only(top: 8), + child: AppNoticeBanner(message: tr.closeWarning, tone: AppNoticeTone.error), + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: AppNoticeBanner( + message: tr.serviceTransactionNote, + tone: AppNoticeTone.warning, + icon: Icons.info_outline, + ), + ), ] else ...[ for (final entry in _inputs.entries) Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 6), child: TextFormField( key: ValueKey('bank-input-${entry.key.field}'), controller: entry.value, - decoration: InputDecoration( - labelText: _inputTitle(context, entry.key), + decoration: _bankFieldDecoration( + _inputTitle(context, entry.key), helperText: entry.key == BankInput.days && widget.form.service == BankService.term ? tr.termMinimumDays : entry.key == BankInput.amount && widget.form.service == BankService.remittance @@ -165,12 +365,21 @@ class _BankServiceDialogState extends State<_BankServiceDialog> { : tr.invalidValue, ), ), - if (widget.form.inputs.any((input) => input.secret)) Text(tr.passwordHint), + if (widget.form.inputs.any((input) => input.secret)) + Text( + tr.passwordHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), ], if (fee != null) Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('${tr.estimatedFee}: $fee ${widget.data.currency}\n${tr.feeHint}'), + padding: const EdgeInsets.only(top: 8), + child: AppInsetBlock( + outlined: true, + child: Text('${tr.estimatedFee}: $fee ${widget.data.currency}\n${tr.feeHint}'), + ), ), ], ), diff --git a/lib/features/blocking/view/user_block_page.dart b/lib/features/blocking/view/user_block_page.dart index ff5b4b1ad..f734c21ea 100644 --- a/lib/features/blocking/view/user_block_page.dart +++ b/lib/features/blocking/view/user_block_page.dart @@ -17,6 +17,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Manage the local block list and the forum's notice ignore rules of the current account. /// @@ -168,41 +169,45 @@ class _UserBlockPageState extends State { final tr = context.t.userBlock; switch (list.status) { case UserBlockListStatus.loading when list.ownerUid != null: - return const ListTile(title: LinearProgressIndicator()); + return const Padding(padding: EdgeInsets.symmetric(vertical: 12), child: LinearProgressIndicator()); case UserBlockListStatus.failed: - return ListTile( - leading: Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error), - title: Text(tr.loadFailed), - trailing: TextButton( - onPressed: () async => context.read().reload(), - child: Text(context.t.general.retry), - ), + return AppNoticeBanner( + message: tr.loadFailed, + tone: AppNoticeTone.error, + actions: [ + TextButton.icon( + onPressed: () async => context.read().reload(), + icon: const Icon(Icons.refresh), + label: Text(context.t.general.retry), + ), + ], ); case UserBlockListStatus.loading || UserBlockListStatus.ready: break; } if (list.ownerUid == null) { - return ListTile(title: Text(tr.invalid)); + return AppNoticeBanner(message: tr.invalid, icon: Icons.login); } if (list.users.isEmpty) { - return ListTile(title: Text(tr.empty)); + return _EmptyLine(icon: Icons.person_off_outlined, text: tr.empty); } return Column( - children: list.users - .map( - (u) => ListTile( - key: ValueKey('blocked-${u.uid}'), - leading: const Icon(Icons.block_outlined), - title: Text(u.username), - subtitle: Text('UID ${u.uid} · ${tr.blockedAt(time: u.blockedAt.yyyyMMDDHHMMSS())}'), - onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': '${u.uid}'}), - trailing: TextButton( - onPressed: () async => unblockUser(context, uid: u.uid, username: u.username), - child: Text(tr.unblock), - ), + children: [ + for (final (index, u) in list.users.indexed) ...[ + if (index > 0) sizedBoxW8H8, + _BlockRow( + key: ValueKey('blocked-${u.uid}'), + icon: Icons.block_outlined, + title: u.username, + subtitle: 'UID ${u.uid} · ${tr.blockedAt(time: u.blockedAt.yyyyMMDDHHMMSS())}', + onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': '${u.uid}'}), + action: TextButton( + onPressed: () async => unblockUser(context, uid: u.uid, username: u.username), + child: Text(tr.unblock), ), - ) - .toList(), + ), + ], + ], ); } @@ -233,50 +238,59 @@ class _UserBlockPageState extends State { loadLabel = tr.reload; } return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ListTile(leading: const Icon(Icons.info_outline), title: Text(tr.entryHelp)), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 18, color: Theme.of(context).colorScheme.primary), + sizedBoxW8H8, + Expanded(child: Text(tr.entryHelp, style: Theme.of(context).textTheme.bodySmall)), + ], + ), + sizedBoxW8H8, // Always visible and apart from the header, disabled while a request is running. - Padding( - padding: edgeInsetsL4R4, - child: Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: _busy ? null : _loadRules, - icon: const Icon(Icons.refresh), - label: Text(loadLabel), - ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: _busy ? null : _loadRules, + icon: const Icon(Icons.refresh), + label: Text(loadLabel), ), ), // Rules are never reported as empty before the forum answered. - if (rules == null && _rulesFailure == null && !_busy) ListTile(title: Text(tr.notLoaded)), + if (rules == null && _rulesFailure == null && !_busy) + _EmptyLine(icon: Icons.cloud_download_outlined, text: tr.notLoaded), if (_rulesFailure != null) - ListTile( - leading: Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error), - title: Text(noticeIgnoreFailureText(context, _rulesFailure!)), - ), - if (rules != null && rules.isEmpty) ListTile(title: Text(tr.empty)), + AppNoticeBanner(message: noticeIgnoreFailureText(context, _rulesFailure!), tone: AppNoticeTone.error), + if (rules != null && rules.isEmpty) _EmptyLine(icon: Icons.notifications_none_outlined, text: tr.empty), if (rules != null) - ...rules.map((r) { - final who = r.everybody ? tr.everybody : tr.userUid(uid: '${r.authorId}'); - final label = '${noticeTypeName(context, r.type)} · $who'; - // The forum's own text when it gives one; the confirmation names the rule the way the list does. - final title = _forumLabelOf(context, r) ?? label; - return ListTile( - key: ValueKey('rule-${r.key}'), - leading: const Icon(Icons.notifications_off_outlined), - title: Text(title), - subtitle: Text(label), - trailing: TextButton( - onPressed: _busy ? null : () async => _removeRule(r, title), - child: Text(tr.remove), - ), - ); - }), + for (final (index, r) in rules.indexed) ...[ + if (index > 0) sizedBoxW8H8, + _buildRule(context, r), + ], ], ); } + Widget _buildRule(BuildContext context, NoticeIgnoreRule r) { + final tr = context.t.userBlock.serverRules; + final who = r.everybody ? tr.everybody : tr.userUid(uid: '${r.authorId}'); + final label = '${noticeTypeName(context, r.type)} · $who'; + // The forum's own text when it gives one; the confirmation names the rule the way the list does. + final title = _forumLabelOf(context, r) ?? label; + return _BlockRow( + key: ValueKey('rule-${r.key}'), + icon: Icons.notifications_off_outlined, + title: title, + subtitle: label, + action: TextButton( + onPressed: _busy ? null : () async => _removeRule(r, title), + child: Text(tr.remove), + ), + ); + } + @override Widget build(BuildContext context) { final tr = context.t.userBlock; @@ -294,34 +308,187 @@ class _UserBlockPageState extends State { body: SafeArea( top: false, bottom: false, - child: ListView( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - children: [ - Card( - child: Padding(padding: edgeInsetsL12T12R12B12, child: Text(tr.localHint)), - ), - BlocBuilder(builder: _buildLocalList), - const Divider(), - // The forum's blacklist has its own page: it lives in the forum account, unlike the list above. - ListTile( - key: const ValueKey('website-blocklist-entry'), - leading: const Icon(Icons.public_outlined), - title: Text(tr.website.entry, style: Theme.of(context).textTheme.titleMedium), - subtitle: Text(tr.website.entryHint), - trailing: const Icon(Icons.chevron_right), - onTap: () async => context.pushNamed(ScreenPaths.websiteBlocklist), - ), - const Divider(), - ListTile( - title: Text(tr.serverRules.title, style: Theme.of(context).textTheme.titleMedium), - subtitle: Text(tr.serverRules.hint), - trailing: _busy ? const SizedBox.square(dimension: 24, child: CircularProgressIndicator()) : null, - onTap: _busy ? null : _loadRules, - ), - _buildRules(context), - ], + child: AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, side, _) => ListView( + padding: side.copyWith(top: 12, bottom: 12).add(context.safePadding()), + children: [ + // 1. The list on this device. + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + tr.localTitle, + icon: Icons.phone_android_outlined, + padding: const EdgeInsets.only(bottom: 8), + ), + AppInsetBlock( + child: Text( + tr.localHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + sizedBoxW12H12, + BlocBuilder(builder: _buildLocalList), + ], + ), + ), + const SizedBox(height: appSurfaceGap), + // 2. The forum's blacklist has its own page: it lives in the forum account, unlike the list above. + AppSurface( + key: const ValueKey('website-blocklist-entry'), + onTap: () async => context.pushNamed(ScreenPaths.websiteBlocklist), + child: Row( + children: [ + const AppIconTile(Icons.public_outlined), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tr.website.entry, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + tr.website.entryHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: Theme.of(context).colorScheme.outline), + ], + ), + ), + const SizedBox(height: appSurfaceGap), + // 3. Notice rules saved in the forum account. + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + tr.serverRules.title, + icon: Icons.notifications_paused_outlined, + padding: const EdgeInsets.only(bottom: 4), + trailing: _busy ? const SizedBox.square(dimension: 24, child: CircularProgressIndicator()) : null, + ), + Text( + tr.serverRules.hint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + sizedBoxW12H12, + _buildRules(context), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// One blocked user or rule: icon tile, name, details and the action that undoes it. +/// +/// The action moves under the text when the row is too narrow for both (small phones, large fonts). +class _BlockRow extends StatelessWidget { + const _BlockRow({ + required this.icon, + required this.title, + required this.subtitle, + required this.action, + this.onTap, + super.key, + }); + + final IconData icon; + final String title; + final String subtitle; + final Widget action; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final text = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)), + Text(subtitle, style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ); + return Material( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(appInnerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: edgeInsetsL12T8R12B8, + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 360 * MediaQuery.textScalerOf(context).scale(1); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppIconTile(icon, size: 32), + sizedBoxW12H12, + Expanded( + child: wide + ? text + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + text, + Align(alignment: AlignmentDirectional.centerEnd, child: action), + ], + ), + ), + if (wide) ...[sizedBoxW8H8, action], + ], + ); + }, + ), ), ), ); } } + +/// A list without entries yet: icon and message in the muted colors. +class _EmptyLine extends StatelessWidget { + const _EmptyLine({required this.icon, required this.text}); + + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return AppInsetBlock( + padding: edgeInsetsL12T12R12B12, + child: Row( + children: [ + Icon(icon, size: 20, color: colorScheme.outline), + sizedBoxW12H12, + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/blocking/view/website_blocklist_page.dart b/lib/features/blocking/view/website_blocklist_page.dart index a9ef746a3..41fab3879 100644 --- a/lib/features/blocking/view/website_blocklist_page.dart +++ b/lib/features/blocking/view/website_blocklist_page.dart @@ -16,6 +16,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/browser_launcher.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Localized text of [error]. String websiteBlocklistErrorText(BuildContext context, WebsiteBlocklistError error) { @@ -309,59 +310,72 @@ class _WebsiteBlocklistViewState extends State<_WebsiteBlocklistView> { final tr = context.t.userBlock.website; final loading = state.lookupStatus == WebsiteLookupStatus.loading; final found = state.lookup; - return Card( - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppFormSection( + title: tr.addTitle, + icon: Icons.person_add_disabled_outlined, + children: [ + TextField( + key: const ValueKey('website-lookup-input'), + controller: _input, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + labelText: tr.inputLabel, + hintText: tr.inputHint, + prefixIcon: const Icon(Icons.search), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), + // A result, or a pending answer, belongs to the input it was asked for. + onChanged: (_) => _cubit.clearLookup(), + onSubmitted: loading || state.writing ? null : (_) async => _lookup(), + ), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text(tr.addTitle, style: Theme.of(context).textTheme.titleMedium), - sizedBoxW8H8, - TextField( - key: const ValueKey('website-lookup-input'), - controller: _input, - keyboardType: TextInputType.url, - decoration: InputDecoration(labelText: tr.inputLabel, hintText: tr.inputHint), - // A result, or a pending answer, belongs to the input it was asked for. - onChanged: (_) => _cubit.clearLookup(), - onSubmitted: loading || state.writing ? null : (_) async => _lookup(), - ), - sizedBoxW8H8, - Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, + FilledButton.tonal(onPressed: loading || state.writing ? null : _lookup, child: Text(tr.lookup)), + if (loading) const SizedBox.square(dimension: 24, child: CircularProgressIndicator()), + ], + ), + if (state.lookupStatus == WebsiteLookupStatus.failed && state.lookupError != null) + _ErrorLine(websiteBlocklistErrorText(context, state.lookupError!)), + if (found != null) + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - FilledButton.tonal(onPressed: loading || state.writing ? null : _lookup, child: Text(tr.lookup)), - if (loading) const SizedBox.square(dimension: 24, child: CircularProgressIndicator()), + Row( + children: [ + const AppIconTile(Icons.person_outline, size: 32), + sizedBoxW12H12, + Expanded( + child: Text( + tr.lookupResult(name: found.displayName, uid: '${found.uid}'), + key: const ValueKey('website-lookup-result'), + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + sizedBoxW8H8, + if (found.alreadyListed) + Text(tr.alreadyListed) + else if (found.canAdd) + FilledButton( + key: const ValueKey('website-add'), + onPressed: state.writing ? null : () async => _add(found), + child: Text(tr.add), + ) + else + Text(tr.cannotAdd), ], ), - if (state.lookupStatus == WebsiteLookupStatus.failed && state.lookupError != null) ...[ - sizedBoxW8H8, - _ErrorLine(websiteBlocklistErrorText(context, state.lookupError!)), - ], - if (found != null) ...[ - sizedBoxW8H8, - Text( - tr.lookupResult(name: found.displayName, uid: '${found.uid}'), - key: const ValueKey('website-lookup-result'), - style: Theme.of(context).textTheme.titleSmall, - ), - sizedBoxW4H4, - if (found.alreadyListed) - Text(tr.alreadyListed) - else if (found.canAdd) - FilledButton( - key: const ValueKey('website-add'), - onPressed: state.writing ? null : () async => _add(found), - child: Text(tr.add), - ) - else - Text(tr.cannotAdd), - ], - ], - ), - ), + ), + ], ); } @@ -378,49 +392,73 @@ class _WebsiteBlocklistViewState extends State<_WebsiteBlocklistView> { final label = localKnown ? (both ? tr.stateBoth : tr.stateWebsiteOnly) : null; final importing = state.importing.contains(row.uid); final canImport = localKnown && list.complete && !both; - return Card( + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return AppInsetBlock( key: ValueKey('website-row-${row.uid}'), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(row.displayName, style: Theme.of(context).textTheme.titleMedium), - Text(label == null ? 'UID ${row.uid}' : 'UID ${row.uid} · $label'), - sizedBoxW4H4, - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (canImport) - OutlinedButton.icon( - key: ValueKey('website-import-${row.uid}'), - onPressed: importing ? null : () async => _import(row), - icon: const Icon(Icons.download_outlined), - label: Text(tr.import), - ), - if (row.removable) - TextButton( - key: ValueKey('website-remove-${row.uid}'), - onPressed: state.writing ? null : () async => _remove(row), - child: Text(tr.remove), - ) - else - Text(tr.cannotRemove, style: Theme.of(context).textTheme.bodySmall), - ], - ), - ], - ), + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppIconTile( + both ? Icons.phone_android_outlined : Icons.public_outlined, + size: 32, + ), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.displayName, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text( + label == null ? 'UID ${row.uid}' : 'UID ${row.uid} · $label', + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (canImport) + OutlinedButton.icon( + key: ValueKey('website-import-${row.uid}'), + onPressed: importing ? null : () async => _import(row), + icon: const Icon(Icons.download_outlined), + label: Text(tr.import), + ), + if (row.removable) + TextButton( + key: ValueKey('website-remove-${row.uid}'), + onPressed: state.writing ? null : () async => _remove(row), + child: Text(tr.remove), + ) + else + Text(tr.cannotRemove, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ], ), ); } - List _buildList(BuildContext context, WebsiteBlocklistState state, UserBlockList local) { + Widget _buildList(BuildContext context, WebsiteBlocklistState state, UserBlockList local) { final tr = context.t.userBlock.website; final list = state.list; final localKnown = local.ownerUid == state.owner && local.status == UserBlockListStatus.ready; - return [ + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final children = [ + AppSectionHeader(tr.title, icon: Icons.public_outlined, padding: EdgeInsets.zero), Wrap( spacing: 8, runSpacing: 4, @@ -446,35 +484,64 @@ class _WebsiteBlocklistViewState extends State<_WebsiteBlocklistView> { ], ), // Never "empty" before the forum answered. - if (state.status == WebsiteBlocklistStatus.initial) ListTile(title: Text(tr.notLoaded)), + if (state.status == WebsiteBlocklistStatus.initial) _NoteLine(tr.notLoaded, icon: Icons.cloud_download_outlined), if (state.error != null) _ErrorLine(websiteBlocklistErrorText(context, state.error!)), if (list != null) ...[ - ListTile( - // Only a complete list gives the total. - title: Text( - list.complete ? tr.count(count: '${list.rows.length}') : tr.countPartial(count: '${list.rows.length}'), + AppInsetBlock( + color: colorScheme.surfaceContainerHigh, + padding: edgeInsetsL12T12R12B12, + child: Row( + children: [ + Icon(Icons.format_list_numbered_outlined, color: colorScheme.primary), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Only a complete list gives the total. + Text( + list.complete + ? tr.count(count: '${list.rows.length}') + : tr.countPartial(count: '${list.rows.length}'), + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + switch (list.quota) { + WebsiteBlocklistQuota(used: final used?, limit: final limit?) => tr.quotaUsed( + used: '$used', + limit: '$limit', + ), + WebsiteBlocklistQuota(limit: final limit?) => tr.quotaLimit(limit: '$limit'), + _ => tr.quotaUnknown, + }, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], ), - subtitle: Text(switch (list.quota) { - WebsiteBlocklistQuota(used: final used?, limit: final limit?) => tr.quotaUsed( - used: '$used', - limit: '$limit', - ), - WebsiteBlocklistQuota(limit: final limit?) => tr.quotaLimit(limit: '$limit'), - _ => tr.quotaUnknown, - }), ), - if (!list.complete) _NoteLine(tr.incomplete), + if (!list.complete) _NoteLine(tr.incomplete, warning: true), if (!localKnown) _NoteLine(tr.localUnknown), if (localKnown && list.complete) - ListTile( - leading: const Icon(Icons.phone_android_outlined), - title: Text(tr.localOnly(count: '${local.uids.difference(list.uids).length}')), + _NoteLine( + tr.localOnly(count: '${local.uids.difference(list.uids).length}'), + icon: Icons.phone_android_outlined, ), // No rows on a partial page is not an empty list. - if (list.rows.isEmpty && list.complete) ListTile(title: Text(tr.empty)), + if (list.rows.isEmpty && list.complete) _NoteLine(tr.empty, icon: Icons.inbox_outlined), ...list.rows.map((row) => _buildRow(context, state, list, row, local, localKnown)), ], ]; + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < children.length; i++) ...[if (i > 0) sizedBoxW8H8, children[i]], + ], + ), + ); } @override @@ -493,19 +560,22 @@ class _WebsiteBlocklistViewState extends State<_WebsiteBlocklistView> { bottom: false, child: BlocBuilder( builder: (context, state) => BlocBuilder( - builder: (context, local) => ListView( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - children: [ - Card( - child: Padding(padding: edgeInsetsL12T12R12B12, child: Text(tr.hint)), - ), - if (state.owner == null) - ListTile(title: Text(context.t.userBlock.invalid)) - else ...[ - _buildAddSection(context, state), - ..._buildList(context, state, local), + builder: (context, local) => AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, side, _) => ListView( + padding: side.copyWith(top: 12, bottom: 12).add(context.safePadding()), + children: [ + AppNoticeBanner(message: tr.hint), + const SizedBox(height: appSurfaceGap), + if (state.owner == null) + AppStateView(icon: Icons.login, message: context.t.userBlock.invalid, scrollable: false) + else ...[ + _buildAddSection(context, state), + const SizedBox(height: appSurfaceGap), + _buildList(context, state, local), + ], ], - ], + ), ), ), ), @@ -522,17 +592,37 @@ class _ErrorLine extends StatelessWidget { final String text; @override - Widget build(BuildContext context) => ListTile( - leading: Icon(Icons.error_outline, color: Theme.of(context).colorScheme.error), - title: Text(text), - ); + Widget build(BuildContext context) => AppNoticeBanner(message: text, tone: AppNoticeTone.error); } +/// A remark about the list (not loaded, incomplete, local only count); [warning] for a limit of the list. class _NoteLine extends StatelessWidget { - const _NoteLine(this.text); + const _NoteLine(this.text, {this.icon = Icons.info_outline, this.warning = false}); final String text; + final IconData icon; + final bool warning; @override - Widget build(BuildContext context) => ListTile(leading: const Icon(Icons.info_outline), title: Text(text)); + Widget build(BuildContext context) { + if (warning) { + return AppNoticeBanner(message: text, tone: AppNoticeTone.warning, icon: Icons.warning_amber_outlined); + } + final colorScheme = Theme.of(context).colorScheme; + return AppInsetBlock( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: colorScheme.outline), + sizedBoxW12H12, + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + ); + } } diff --git a/lib/features/blocking/widgets/block_aware_post.dart b/lib/features/blocking/widgets/block_aware_post.dart index 7376e0240..d0dc0970f 100644 --- a/lib/features/blocking/widgets/block_aware_post.dart +++ b/lib/features/blocking/widgets/block_aware_post.dart @@ -7,6 +7,7 @@ import 'package:tsdm_client/features/blocking/utils/block_filter.dart'; import 'package:tsdm_client/features/blocking/widgets/user_block_button.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Shows [post] through [builder], or a placeholder keeping its floor when the author is blocked locally. /// @@ -84,28 +85,58 @@ class BlockedPostPlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { final tr = context.t.userBlock; + final colorScheme = Theme.of(context).colorScheme; + final action = pending + ? TextButton( + onPressed: () async => context.read().reload(), + child: Text(context.t.general.retry), + ) + : TextButton( + onPressed: () async => unblockUser(context, uid: uid, username: username), + child: Text(tr.unblock), + ); + // A muted, compact floor: the floor number keeps its place, the content is replaced by one line and one action. return Card( margin: EdgeInsets.zero, + color: colorScheme.surfaceContainerLow, child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Row( - children: [ - Text(floor == null ? '#' : '#$floor', style: Theme.of(context).textTheme.labelMedium), - sizedBoxW8H8, - Icon(_pendingIcon(pending: pending, failed: failed), size: 18), - sizedBoxW8H8, - Expanded(child: Text(pending ? _pendingText(tr, failed: failed) : tr.postPlaceholder)), - if (pending) - TextButton( - onPressed: () async => context.read().reload(), - child: Text(context.t.general.retry), - ) - else - TextButton( - onPressed: () async => unblockUser(context, uid: uid, username: username), - child: Text(tr.unblock), - ), - ], + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + child: LayoutBuilder( + builder: (context, constraints) { + // The action beside the text only when it leaves the text a readable width: on a narrow phone or with a + // large font the button would squeeze the message to a word per line, so it moves under the message. + final scale = MediaQuery.textScalerOf(context).scale(1); + final besideText = constraints.maxWidth >= 420 * scale; + final texts = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + floor == null ? '#' : '#$floor', + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.outline), + ), + Text( + pending ? _pendingText(tr, failed: failed) : tr.postPlaceholder, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (!besideText) Align(alignment: AlignmentDirectional.centerEnd, child: action), + ], + ); + return Row( + crossAxisAlignment: besideText ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + AppIconTile( + _pendingIcon(pending: pending, failed: failed), + size: 32, + color: pending && failed ? colorScheme.errorContainer : colorScheme.surfaceContainerHighest, + foregroundColor: pending && failed ? colorScheme.onErrorContainer : colorScheme.outline, + ), + sizedBoxW12H12, + Expanded(child: texts), + if (besideText) action, + ], + ); + }, ), ), ); @@ -140,38 +171,31 @@ class BlockedThreadNotice extends StatelessWidget { @override Widget build(BuildContext context) { final tr = context.t.userBlock; - return Center( - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(_pendingIcon(pending: pending, failed: failed), size: 48), - sizedBoxW8H8, - Text(pending ? _pendingText(tr, failed: failed) : tr.threadHidden, textAlign: TextAlign.center), - sizedBoxW8H8, - Wrap( - spacing: 8, - children: [ - if (Navigator.of(context).canPop()) - OutlinedButton( - onPressed: () => Navigator.of(context).maybePop(), - child: Text(tr.goBack), - ), - if (pending) - FilledButton( - onPressed: () async => context.read().reload(), - child: Text(context.t.general.retry), - ) - else - FilledButton( - onPressed: () async => unblockUser(context, uid: uid, username: username), - child: Text(tr.unblock), - ), - ], + return AppStateView( + icon: _pendingIcon(pending: pending, failed: failed), + error: pending && failed, + message: pending ? _pendingText(tr, failed: failed) : tr.threadHidden, + action: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (Navigator.of(context).canPop()) + OutlinedButton( + onPressed: () => Navigator.of(context).maybePop(), + child: Text(tr.goBack), ), - ], - ), + if (pending) + FilledButton( + onPressed: () async => context.read().reload(), + child: Text(context.t.general.retry), + ) + else + FilledButton( + onPressed: () async => unblockUser(context, uid: uid, username: username), + child: Text(tr.unblock), + ), + ], ), ); } diff --git a/lib/features/blocking/widgets/notice_ignore_actions.dart b/lib/features/blocking/widgets/notice_ignore_actions.dart index 00b634da5..4990c051c 100644 --- a/lib/features/blocking/widgets/notice_ignore_actions.dart +++ b/lib/features/blocking/widgets/notice_ignore_actions.dart @@ -11,6 +11,7 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Localized text of [failure]. String noticeIgnoreFailureText(BuildContext context, NoticeIgnoreFailure failure) => @@ -105,17 +106,34 @@ Future showNoticeIgnoreDialog( final everybody = await showDialog( context: navigator.context, builder: (context) => SimpleDialog( - title: Text(tr.title), + title: Row( + children: [ + const AppIconTile(Icons.notifications_paused_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.title)), + ], + ), + contentPadding: const EdgeInsets.fromLTRB(16, 12, 16, 16), children: [ - Padding(padding: const EdgeInsets.symmetric(horizontal: 24), child: Text(tr.hint)), + AppInsetBlock( + child: Text( + tr.hint, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + sizedBoxW8H8, if (target.authorId > 0) - SimpleDialogOption( - onPressed: () => Navigator.of(context).pop(false), - child: Text(tr.ignoreThisUser(type: typeName)), + _IgnoreOption( + icon: Icons.person_off_outlined, + text: tr.ignoreThisUser(type: typeName), + onTap: () => Navigator.of(context).pop(false), ), - SimpleDialogOption( - onPressed: () => Navigator.of(context).pop(true), - child: Text(tr.ignoreEverybody(type: typeName)), + _IgnoreOption( + icon: Icons.groups_outlined, + text: tr.ignoreEverybody(type: typeName), + onTap: () => Navigator.of(context).pop(true), ), ], ), @@ -164,6 +182,42 @@ Future showNoticeIgnoreDialog( showSnackBar(context: navigator.context, message: message); } +/// One choice of the notice rule dialog: a rounded, focusable row with an icon. +class _IgnoreOption extends StatelessWidget { + const _IgnoreOption({required this.icon, required this.text, required this.onTap}); + + final IconData icon; + final String text; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: edgeInsetsL12T12R12B12, + child: Row( + children: [ + Icon(icon, color: colorScheme.primary), + sizedBoxW12H12, + Expanded(child: Text(text)), + Icon(Icons.chevron_right, color: colorScheme.outline), + ], + ), + ), + ), + ), + ); + } +} + /// Show a progress dialog with [message] on [navigator]; call the returned function to close it. /// /// Tapping outside does not close it. The back button does, so a request that never ends can not trap the user; the diff --git a/lib/features/chat/view/chat_history_page.dart b/lib/features/chat/view/chat_history_page.dart index a4389a1fa..3804ee802 100644 --- a/lib/features/chat/view/chat_history_page.dart +++ b/lib/features/chat/view/chat_history_page.dart @@ -1,24 +1,24 @@ import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/chat/bloc/chat_history_bloc.dart'; import 'package:tsdm_client/features/chat/models/editor_features.dart'; import 'package:tsdm_client/features/chat/repository/chat_repository.dart'; +import 'package:tsdm_client/features/chat/widgets/chat_frame.dart'; import 'package:tsdm_client/features/chat/widgets/chat_message_card.dart'; import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; import 'package:tsdm_client/features/notification/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/reply_bar/bloc/reply_bloc.dart'; import 'package:tsdm_client/widgets/reply_bar/models/reply_types.dart'; import 'package:tsdm_client/widgets/reply_bar/reply_bar.dart'; import 'package:tsdm_client/widgets/reply_bar/repository/reply_repository.dart'; -import 'package:tsdm_client/widgets/single_line_text.dart'; /// Chat history page shows full chat history with another user [uid] and an /// area to send new messages. @@ -63,29 +63,36 @@ final class _ChatHistoryPageState extends State { } context.read().add(ChatHistoryLoadHistoryRequested(uid: widget.uid, page: state.previousPage)); }, - child: ListView.separated( - // Reverse the list view and data received from server to let scroll - // position keep the same after new pages of data. - // See `messages` in `ChatHistoryState` for details. - reverse: true, - shrinkWrap: true, - controller: _scrollController, - separatorBuilder: (context, index) => const Divider(thickness: 0.5), - itemCount: messages.length, - itemBuilder: (context, index) => ChatMessageCard(messages[index]), + // Bubbles centered at a readable width; the list keeps the full width for scrolling and loading older pages. + child: AppCenteredList( + maxWidth: appReadingMaxWidth, + builder: (context, side, _) => ListView.builder( + // Reverse the list view and data received from server to let scroll + // position keep the same after new pages of data. + // See `messages` in `ChatHistoryState` for details. + reverse: true, + shrinkWrap: true, + controller: _scrollController, + padding: side.copyWith(top: 8, bottom: 8), + itemCount: messages.length, + itemBuilder: (context, index) => ChatMessageCard(messages[index]), + ), ), ); + // Only the messages keep the side safe area: the reply bar pads the insets itself so its background reaches the + // screen edges in landscape. return Column( children: [ - Expanded(child: messageList), - sizedBoxW12H12, - ReplyBar( - controller: _replyBarController, - replyType: ReplyTypes.chatHistory, - chatHistorySendTarget: state.sendTarget, - disabledEditorFeatures: chatPagesDisabledFeatures, - fullScreenDisabledEditorFeatures: chatPagesDisabledFeatures, + Expanded(child: SafeArea(bottom: false, child: messageList)), + ChatComposerFrame( + child: ReplyBar( + controller: _replyBarController, + replyType: ReplyTypes.chatHistory, + chatHistorySendTarget: state.sendTarget, + disabledEditorFeatures: chatPagesDisabledFeatures, + fullScreenDisabledEditorFeatures: chatPagesDisabledFeatures, + ), ), ], ); @@ -171,29 +178,21 @@ final class _ChatHistoryPageState extends State { ChatHistoryStatus.loading || ChatHistoryStatus.success || ChatHistoryStatus.loadingMore => _buildContent(context, state), - ChatHistoryStatus.failure => buildRetryButton( - context, - () => context.read().add( - ChatHistoryLoadHistoryRequested(uid: widget.uid, page: state.pageNumber), + ChatHistoryStatus.failure => SafeArea( + bottom: false, + child: buildRetryButton( + context, + () => context.read().add( + ChatHistoryLoadHistoryRequested(uid: widget.uid, page: state.pageNumber), + ), ), ), }; - PreferredSize? bottom; + PreferredSizeWidget? bottom; if ((state.user.username != null || state.user.uid != null) && state.messageCount > 0) { - bottom = PreferredSize( - preferredSize: const Size(kToolbarHeight / 2, kToolbarHeight / 2), - child: Padding( - padding: edgeInsetsL12R12B12, - child: Row( - children: [ - SingleLineText( - tr.info(user: state.user.username ?? state.user.uid ?? '', count: state.messageCount), - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ), + bottom = ChatPeerHeader( + text: tr.info(user: state.user.username ?? state.user.uid ?? '', count: state.messageCount), ); } @@ -201,7 +200,7 @@ final class _ChatHistoryPageState extends State { // Required by chat_bottom_container in reply bar. resizeToAvoidBottomInset: false, appBar: AppBar(title: Text(tr.title), bottom: bottom), - body: SafeArea(bottom: false, child: body), + body: body, ); }, ), diff --git a/lib/features/chat/view/chat_page.dart b/lib/features/chat/view/chat_page.dart index 0b5a845e1..ff1daeae1 100644 --- a/lib/features/chat/view/chat_page.dart +++ b/lib/features/chat/view/chat_page.dart @@ -3,24 +3,24 @@ import 'dart:async'; import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/chat/bloc/chat_bloc.dart'; import 'package:tsdm_client/features/chat/models/editor_features.dart'; import 'package:tsdm_client/features/chat/repository/chat_repository.dart'; +import 'package:tsdm_client/features/chat/widgets/chat_frame.dart'; import 'package:tsdm_client/features/chat/widgets/chat_message_card.dart'; import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; import 'package:tsdm_client/features/notification/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/reply_bar/bloc/reply_bloc.dart'; import 'package:tsdm_client/widgets/reply_bar/models/reply_types.dart'; import 'package:tsdm_client/widgets/reply_bar/reply_bar.dart'; import 'package:tsdm_client/widgets/reply_bar/repository/reply_repository.dart'; -import 'package:tsdm_client/widgets/single_line_text.dart'; /// Chat page shows a page to let user chat with another user. /// @@ -76,24 +76,31 @@ final class _ChatPageState extends State { } context.read().add(ChatFetchHistoryRequested(state.uid)); }, - child: ListView.separated( - controller: _scrollController, - separatorBuilder: (context, index) => const Divider(thickness: 0.5), - itemCount: messages.length, - itemBuilder: (context, index) => ChatMessageCard(messages[index]), + // Bubbles centered at a readable width; the list keeps the full width for scrolling and pull to refresh. + child: AppCenteredList( + maxWidth: appReadingMaxWidth, + builder: (context, side, _) => ListView.builder( + controller: _scrollController, + padding: side.copyWith(top: 8, bottom: 8), + itemCount: messages.length, + itemBuilder: (context, index) => ChatMessageCard(messages[index]), + ), ), ); + // Only the messages keep the side safe area: the reply bar pads the insets itself so its background reaches the + // screen edges in landscape. return Column( children: [ - Expanded(child: messageList), - sizedBoxW12H12, - ReplyBar( - controller: _replyBarController, - replyType: ReplyTypes.chat, - chatSendTarget: state.chatSendTarget, - disabledEditorFeatures: chatPagesDisabledFeatures, - fullScreenDisabledEditorFeatures: chatPagesDisabledFeatures, + Expanded(child: SafeArea(bottom: false, child: messageList)), + ChatComposerFrame( + child: ReplyBar( + controller: _replyBarController, + replyType: ReplyTypes.chat, + chatSendTarget: state.chatSendTarget, + disabledEditorFeatures: chatPagesDisabledFeatures, + fullScreenDisabledEditorFeatures: chatPagesDisabledFeatures, + ), ), ], ); @@ -193,9 +200,12 @@ final class _ChatPageState extends State { // A reload keeps the messages on screen instead of flashing a spinner. ChatStatus.loading when state.messageList.isEmpty => const CenteredCircularIndicator(), ChatStatus.loading || ChatStatus.success => _buildContent(context, state), - ChatStatus.failure => buildRetryButton( - context, - () => context.read().add(ChatFetchHistoryRequested(widget.uid)), + ChatStatus.failure => SafeArea( + bottom: false, + child: buildRetryButton( + context, + () => context.read().add(ChatFetchHistoryRequested(widget.uid)), + ), ), }; @@ -216,23 +226,13 @@ final class _ChatPageState extends State { onPressed: () async => context.dispatchAsUrl(state.chatHistoryUrl), ), ], - bottom: PreferredSize( - preferredSize: const Size(kToolbarHeight / 2, kToolbarHeight / 2), - child: Padding( - padding: edgeInsetsL12R12B12, - child: Row( - children: [ - SingleLineText( - '${tr.hint(user: widget.username ?? widget.uid)} ' - '${state.online ? tr.online : tr.offline}', - style: Theme.of(context).textTheme.labelMedium, - ), - ], - ), - ), + bottom: ChatPeerHeader( + text: tr.hint(user: widget.username ?? widget.uid), + status: state.online ? context.t.profilePage.online : context.t.profilePage.offline, + online: state.online, ), ), - body: SafeArea(bottom: false, child: body), + body: body, ); }, ), diff --git a/lib/features/chat/widgets/chat_frame.dart b/lib/features/chat/widgets/chat_frame.dart new file mode 100644 index 000000000..795ccd3ba --- /dev/null +++ b/lib/features/chat/widgets/chat_frame.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// Height of [ChatPeerHeader] when used as the bottom of an app bar. +const chatPeerHeaderHeight = 36.0; + +/// Line under the title of the chat pages: who the conversation is with and, optionally, a status pill. +class ChatPeerHeader extends StatelessWidget implements PreferredSizeWidget { + /// Constructor. + const ChatPeerHeader({required this.text, this.status, this.online = false, super.key}); + + /// Who the conversation is with, already translated. + final String text; + + /// Optional status text shown in a pill (online or offline), already translated. + final String? status; + + /// Color the status pill as online. + final bool online; + + @override + Size get preferredSize => const Size.fromHeight(chatPeerHeaderHeight); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // A fixed height line under the app bar: large fonts are clamped a little so the line and its pill are not cut. + return MediaQuery.withClampedTextScaling( + maxScaleFactor: 1.2, + child: SizedBox( + height: chatPeerHeaderHeight, + child: Padding( + padding: edgeInsetsL16R16B12.copyWith(bottom: 8), + child: Row( + children: [ + Icon(Icons.person_outline, size: 16, color: colorScheme.outline), + sizedBoxW4H4, + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + if (status != null) ...[ + sizedBoxW8H8, + DecoratedBox( + decoration: BoxDecoration( + color: online ? colorScheme.primaryContainer : colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 8, color: online ? colorScheme.primary : colorScheme.outline), + sizedBoxW4H4, + Text( + status!, + maxLines: 1, + style: textTheme.labelSmall?.copyWith( + color: online ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Frame of the message input of the chat pages: full width background with a hairline on top, the input itself +/// centered at the width of the messages. +class ChatComposerFrame extends StatelessWidget { + /// Constructor. + const ChatComposerFrame({required this.child, super.key}); + + /// The reply bar. + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + // Same color as the collapsed reply bar, so the frame and the bar read as one area. The hairline on top is + // drawn by the reply bar itself (UI phase 3), not twice. + color: colorScheme.surfaceContainerLow, + ), + child: AppContentWidth(maxWidth: appReadingMaxWidth, child: child), + ); + } +} diff --git a/lib/features/chat/widgets/chat_message_card.dart b/lib/features/chat/widgets/chat_message_card.dart index 26922e8f0..6b28a45e1 100644 --- a/lib/features/chat/widgets/chat_message_card.dart +++ b/lib/features/chat/widgets/chat_message_card.dart @@ -1,13 +1,30 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/extensions/date_time.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/chat/models/models.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/munched_html.dart'; +/// Whether [message] was sent by the logged user [currentUid] / [currentUsername]. +/// +/// The chat history carries the uid of every author; the chat dialog only names them. Unknown stays "not mine": the +/// message is then shown like one of the other user, never attributed to the current account by guess. +bool isOwnChatMessage(ChatMessage message, {required int? currentUid, required String? currentUsername}) { + if (message.authorUid != null && currentUid != null) { + return message.authorUid == '$currentUid'; + } + return message.author != null && currentUsername != null && message.author == currentUsername; +} + /// Widget to show a chat message. +/// +/// A bubble: messages of the current user on the end side in the primary container color, the other user's on the +/// start side with the avatar. At most 560 wide (or 85% of narrow windows) so long lines stay readable. final class ChatMessageCard extends StatelessWidget { /// Constructor. const ChatMessageCard(this.chatMessage, {super.key}); @@ -20,46 +37,100 @@ final class ChatMessageCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: edgeInsetsL8R8, - child: Column( - children: [ - ListTile( - leading: GestureDetector( - onTap: chatMessage.author != null - ? () async => context.pushNamed( - ScreenPaths.profile, - queryParameters: {'uid': ?chatMessage.authorUid, 'username': ?chatMessage.author}, - ) - : null, - child: HeroUserAvatar( - username: chatMessage.author ?? '', - avatarUrl: chatMessage.authorAvatarUrl, - disableHero: true, - ), - ), - title: GestureDetector( - onTap: chatMessage.author != null - ? () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'username': chatMessage.author}) - : null, - child: Align(alignment: Alignment.centerLeft, child: Text(chatMessage.author ?? '')), - ), - subtitle: switch (chatMessage.dateTime) { - null => null, - final t when chatMessage.dateOnly => Text(t.yyyyMMDD()), - final t => Text(t.yyyyMMDDHHMMSS()), - }, - ), - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: edgeInsetsL16R16, - child: MunchedHtml(chatMessage.message), + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final currentUser = context.readOrNull()?.currentUser; + final mine = isOwnChatMessage(chatMessage, currentUid: currentUser?.uid, currentUsername: currentUser?.username); + + final time = switch (chatMessage.dateTime) { + null => null, + final t when chatMessage.dateOnly => t.yyyyMMDD(), + final t => t.yyyyMMDDHHMMSS(), + }; + + final avatar = GestureDetector( + onTap: chatMessage.author != null + ? () async => context.pushNamed( + ScreenPaths.profile, + queryParameters: {'uid': ?chatMessage.authorUid, 'username': ?chatMessage.author}, + ) + : null, + child: HeroUserAvatar( + username: chatMessage.author ?? '', + avatarUrl: chatMessage.authorAvatarUrl, + disableHero: true, + ), + ); + + final header = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: GestureDetector( + onTap: chatMessage.author != null + ? () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'username': chatMessage.author}) + : null, + child: Text( + chatMessage.author ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600), ), ), + ), + if (time != null) ...[ + sizedBoxW8H8, + Text(time, style: textTheme.labelSmall?.copyWith(color: colorScheme.outline)), ], + ], + ); + + final bubble = DecoratedBox( + decoration: BoxDecoration( + color: mine ? colorScheme.primaryContainer : colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(mine ? appSurfaceRadius : 4), + topRight: Radius.circular(mine ? 4 : appSurfaceRadius), + bottomLeft: const Radius.circular(appSurfaceRadius), + bottomRight: const Radius.circular(appSurfaceRadius), + ), + ), + child: Padding( + padding: edgeInsetsL12T8R12B8, + child: DefaultTextStyle.merge( + style: TextStyle(color: mine ? colorScheme.onPrimaryContainer : colorScheme.onSurface), + child: MunchedHtml(chatMessage.message), + ), ), ); + + return LayoutBuilder( + builder: (context, constraints) { + final maxBubbleWidth = constraints.maxWidth < 600 ? constraints.maxWidth * 0.85 : 560.0; + final content = ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxBubbleWidth), + child: Column( + crossAxisAlignment: mine ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + Padding(padding: edgeInsetsL4R4, child: header), + sizedBoxW4H4, + bubble, + ], + ), + ); + return Padding( + padding: edgeInsetsT4B4, + child: Row( + mainAxisAlignment: mine ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!mine) ...[avatar, sizedBoxW8H8], + Flexible(child: content), + if (mine) ...[sizedBoxW8H8, avatar], + ], + ), + ); + }, + ); } } diff --git a/lib/features/checkin/bloc/checkin_bloc.dart b/lib/features/checkin/bloc/checkin_bloc.dart index 6b2d8abad..f0b3323e7 100644 --- a/lib/features/checkin/bloc/checkin_bloc.dart +++ b/lib/features/checkin/bloc/checkin_bloc.dart @@ -28,6 +28,7 @@ final class CheckinBloc extends Bloc { super(const CheckinStateInitial()) { on(_onCheckinRequested); on(_onCheckinAuthChanged); + on(_onCheckinStatusRequested); _authStreamSub = _authenticationRepository.status.listen( (status) => add(CheckinAuthChanged(authed: status is AuthStatusAuthed)), ); @@ -59,6 +60,28 @@ final class CheckinBloc extends Bloc { emit(CheckinStateFailed(result)); } + Future _onCheckinStatusRequested(CheckinStatusRequested event, Emitter emit) async { + final uid = _authenticationRepository.currentUser?.uid; + if (uid == null) { + emit(const CheckinStateNeedLogin()); + return; + } + if (state is CheckinStateLoading) { + return; + } + final checked = await _checkinRepository.checkedInToday(uid); + // The account may have changed while reading. + if (_authenticationRepository.currentUser?.uid != uid || state is CheckinStateLoading) { + return; + } + if (checked) { + emit(const CheckinStateChecked()); + } else if (state is CheckinStateChecked) { + // A new day, or another account without a record. + emit(const CheckinStateInitial()); + } + } + void _onCheckinAuthChanged(CheckinAuthChanged event, Emitter emit) { if (event.authed) { if (state is CheckinStateLoading) { diff --git a/lib/features/checkin/bloc/checkin_event.dart b/lib/features/checkin/bloc/checkin_event.dart index 2673784c7..87dadd30b 100644 --- a/lib/features/checkin/bloc/checkin_event.dart +++ b/lib/features/checkin/bloc/checkin_event.dart @@ -27,3 +27,13 @@ final class CheckinAuthChanged extends CheckinEvent with CheckinAuthChangedMappa /// Latest auth status. final bool authed; } + +/// Ask whether the current account already checked in today, from the record of this app. +/// +/// Sent when a page showing the check-in state opens or reloads; the answer is [CheckinStateChecked] or the initial +/// state, a running check-in is left alone. +@MappableClass() +final class CheckinStatusRequested extends CheckinEvent with CheckinStatusRequestedMappable { + /// Constructor. + const CheckinStatusRequested() : super(); +} diff --git a/lib/features/checkin/bloc/checkin_state.dart b/lib/features/checkin/bloc/checkin_state.dart index 61fd140e7..5834fc36e 100644 --- a/lib/features/checkin/bloc/checkin_state.dart +++ b/lib/features/checkin/bloc/checkin_state.dart @@ -46,3 +46,13 @@ final class CheckinStateSuccess extends CheckinState with CheckinStateSuccessMap /// Error text. final String message; } + +/// The current account checked in today: recorded by this app in an earlier run or by the auto check-in. +/// +/// A check-in of this run ends in [CheckinStateSuccess] (or [CheckinStateFailed] with an "already checked in" +/// answer) instead, which count as checked in as well. +@MappableClass() +final class CheckinStateChecked extends CheckinState with CheckinStateCheckedMappable { + /// Constructor. + const CheckinStateChecked() : super(); +} diff --git a/lib/features/checkin/repository/checkin_repository.dart b/lib/features/checkin/repository/checkin_repository.dart index d7663ad73..a9526f066 100644 --- a/lib/features/checkin/repository/checkin_repository.dart +++ b/lib/features/checkin/repository/checkin_repository.dart @@ -1,4 +1,5 @@ import 'package:tsdm_client/features/checkin/models/models.dart'; +import 'package:tsdm_client/features/checkin/utils/checkin_day.dart'; import 'package:tsdm_client/features/checkin/utils/do_checkin.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; @@ -12,6 +13,12 @@ final class CheckinRepository with LoggerMixin { final StorageProvider _storageProvider; + /// Whether this app recorded a check-in of the account [uid] today (a manual or automatic check-in that succeeded + /// or was answered "already checked in"). + /// + /// Only what the app did itself: a check-in made on the website is not known until the next attempt here. + Future checkedInToday(int uid) async => isCheckedInToday(await _storageProvider.getLastCheckinTime(uid)); + /// Perform a checkin for user [uid]. Future checkin(int uid, CheckinFeeling feeling, String message) async { final netClient = getIt.get(); diff --git a/lib/features/checkin/view/auto_checkin_page.dart b/lib/features/checkin/view/auto_checkin_page.dart index c322c39e6..d1d3f937e 100644 --- a/lib/features/checkin/view/auto_checkin_page.dart +++ b/lib/features/checkin/view/auto_checkin_page.dart @@ -2,13 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; -import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/features/checkin/bloc/auto_checkin_bloc.dart'; import 'package:tsdm_client/features/checkin/models/models.dart'; import 'package:tsdm_client/features/checkin/widgets/auto_checkin_user_card.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; -import 'package:tsdm_client/widgets/tips.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Page to display info about auto checkin status. class AutoCheckinPage extends StatefulWidget { @@ -42,41 +41,121 @@ class _AutoCheckinPageState extends State { succeededList = s; failedList = f; } + final running = [for (final e in runningList) AutoCheckinUserCard(e, tr.user.running)]; + final waiting = [for (final e in waitingList) AutoCheckinUserCard(e, tr.user.waiting)]; + final succeeded = [ + for (final e in succeededList) + AutoCheckinUserCard( + // Ok to use record. + // ignore: avoid_positional_fields_in_records + e.$1, + // Ok to use record. + // ignore: avoid_positional_fields_in_records + CheckinResult.message(context, e.$2), + failure: false, + ), + ]; + final failed = [ + for (final e in failedList) + AutoCheckinUserCard( + // Ok to use record. + // ignore: avoid_positional_fields_in_records + e.$1, + // Ok to use record. + // ignore: avoid_positional_fields_in_records + CheckinResult.message(context, e.$2), + // Ok to use record. + // ignore: avoid_positional_fields_in_records + failure: e.$2 is! CheckinResultAlreadyChecked, + ), + ]; + final total = running.length + waiting.length + succeeded.length + failed.length; + final done = succeeded.length + failed.length; + final colorScheme = Theme.of(context).colorScheme; + return Scaffold( appBar: AppBar(title: Text(tr.title)), body: SafeArea( bottom: false, - child: ListView( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - children: [ - Tips(tr.detail, enablePadding: false), - ...runningList.map((e) => AutoCheckinUserCard(e, tr.user.running)), - ...waitingList.map((e) => AutoCheckinUserCard(e, tr.user.waiting)), - ...succeededList.map( - (e) => AutoCheckinUserCard( - // Ok to use record. - // ignore: avoid_positional_fields_in_records - e.$1, - // Ok to use record. - // ignore: avoid_positional_fields_in_records - CheckinResult.message(context, e.$2), - failure: false, - ), - ), - ...failedList.map( - (e) => AutoCheckinUserCard( - // Ok to use record. - // ignore: avoid_positional_fields_in_records - e.$1, - // Ok to use record. - // ignore: avoid_positional_fields_in_records - CheckinResult.message(context, e.$2), - // Ok to use record. - // ignore: avoid_positional_fields_in_records - failure: e.$2 is! CheckinResultAlreadyChecked, - ), - ), - ].insertBetween(sizedBoxW8H8), + child: AppCenteredList( + builder: (context, side, width) { + final columns = appColumnsFor(width); + final gap = width < 600 ? appSurfaceGapCompact : appSurfaceGap; + List section(String title, IconData icon, List cards) => [ + if (cards.isNotEmpty) ...[ + SizedBox(height: gap), + AppSectionHeader('$title · ${cards.length}', icon: icon), + for (var row = 0; row < appRowCount(cards.length, columns); row++) + Padding( + padding: EdgeInsets.only(top: row == 0 ? 0 : gap), + child: AppColumnsRow( + row: row, + columns: columns, + count: cards.length, + gap: gap, + itemBuilder: (context, index) => cards[index], + ), + ), + ], + ]; + return ListView( + padding: side.copyWith(top: 12, bottom: 12).add(context.safePadding()), + children: [ + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const AppIconTile(Icons.domain_verification_outlined), + sizedBoxW12H12, + Expanded( + child: Text( + tr.detail, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + if (total > 0) ...[ + sizedBoxW12H12, + ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: LinearProgressIndicator(value: done / total, minHeight: 6), + ), + sizedBoxW8H8, + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + AppInfoPill(icon: Icons.sync, label: '${tr.user.running} ${running.length}'), + AppInfoPill( + icon: Icons.schedule_outlined, + label: '${tr.user.waiting} ${waiting.length}', + ), + AppInfoPill( + icon: Icons.check_circle_outline, + label: '${tr.user.success} ${succeeded.length}', + ), + AppInfoPill(icon: Icons.error_outline, label: '${tr.user.failure} ${failed.length}'), + ], + ), + ] else if (state is AutoCheckinStateLoading || state is AutoCheckinStatePreparing) ...[ + sizedBoxW12H12, + const LinearProgressIndicator(), + ], + ], + ), + ), + ...section(tr.user.running, Icons.sync, running), + ...section(tr.user.waiting, Icons.schedule_outlined, waiting), + ...section(tr.user.failure, Icons.error_outline, failed), + ...section(tr.user.success, Icons.check_circle_outline, succeeded), + ], + ); + }, ), ), ); diff --git a/lib/features/checkin/widgets/auto_checkin_user_card.dart b/lib/features/checkin/widgets/auto_checkin_user_card.dart index 90c82e12f..212f0954d 100644 --- a/lib/features/checkin/widgets/auto_checkin_user_card.dart +++ b/lib/features/checkin/widgets/auto_checkin_user_card.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Card to show auto checkin info. class AutoCheckinUserCard extends StatelessWidget { @@ -18,42 +19,53 @@ class AutoCheckinUserCard extends StatelessWidget { @override Widget build(BuildContext context) { - Color? backgroundColor; - Color? foregroundColor; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // null: waiting or running, false: done (checked in or already checked), true: failed. + final (IconData icon, Color tile, Color onTile) = switch (failure) { + true => (Icons.error_outline, colorScheme.errorContainer, colorScheme.onErrorContainer), + false => (Icons.check_circle_outline, colorScheme.primaryContainer, colorScheme.onPrimaryContainer), + null => (Icons.schedule_outlined, colorScheme.surfaceContainerHighest, colorScheme.onSurfaceVariant), + }; + final username = userInfo.username ?? ''; - switch (failure) { - case true: - backgroundColor = Theme.of(context).colorScheme.errorContainer; - foregroundColor = Theme.of(context).colorScheme.onErrorContainer; - default: - break; - } - - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: DecoratedBox( - decoration: BoxDecoration(color: backgroundColor), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - ListTile( - leading: CircleAvatar(child: Text(userInfo.username![0])), - title: Text(userInfo.username!), - subtitle: Text('${userInfo.uid!}'), - contentPadding: EdgeInsets.zero, - minVerticalPadding: 0, - minTileHeight: 0, + CircleAvatar(child: Text(username.isEmpty ? '?' : username.characters.first)), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(username, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text( + 'UID ${userInfo.uid ?? '-'}', + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), ), - if (message != null) ...[ - sizedBoxW8H8, - Text(message!, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: foregroundColor)), - ], + sizedBoxW8H8, + AppIconTile(icon, size: 32, color: tile, foregroundColor: onTile), ], ), - ), + if (message != null) ...[ + sizedBoxW8H8, + AppInsetBlock( + color: failure ?? false ? colorScheme.errorContainer : null, + child: Text( + message!, + style: textTheme.bodySmall?.copyWith( + color: failure ?? false ? colorScheme.onErrorContainer : colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ], ), ); } diff --git a/lib/features/checkin/widgets/checkin_button.dart b/lib/features/checkin/widgets/checkin_button.dart index 7fe9b1674..3a8a161bd 100644 --- a/lib/features/checkin/widgets/checkin_button.dart +++ b/lib/features/checkin/widgets/checkin_button.dart @@ -9,7 +9,7 @@ import 'package:tsdm_client/utils/show_toast.dart'; /// Widget provides ability to checkin. class CheckinButton extends StatelessWidget { /// Constructor. - const CheckinButton({this.enableSnackBar = false, this.useIcon = false, super.key}); + const CheckinButton({this.enableSnackBar = false, this.useIcon = false, this.label, super.key}); /// Enable snack bar feedback after checkin action. /// @@ -23,6 +23,18 @@ class CheckinButton extends StatelessWidget { /// Enabling this field will make the widget not pressable. final bool useIcon; + /// Show a filled button with this label instead of an icon button, e.g. on the homepage greeting card. + final String? label; + + /// The filled button used when [label] is set. + Widget _buildLabeled(BuildContext context, {required Widget icon, required VoidCallback? onPressed}) => + FilledButton.icon( + style: FilledButton.styleFrom(minimumSize: const Size(0, 48)), + icon: icon, + label: Text(label!), + onPressed: onPressed, + ); + Future _showCheckinFailedSnackBar(BuildContext context, CheckinResult result) async { if (!context.mounted) { return; @@ -54,6 +66,9 @@ class CheckinButton extends StatelessWidget { if (useIcon) { return sizedCircularProgressIndicator; } + if (label != null) { + return _buildLabeled(context, icon: sizedCircularProgressIndicator, onPressed: null); + } return IconButton(icon: sizedCircularProgressIndicator, tooltip: tooltip, onPressed: null); } @@ -61,10 +76,43 @@ class CheckinButton extends StatelessWidget { return const Icon(Icons.domain_verification_outlined); } + // Checked in today: recorded by this app (this run, an earlier run or the auto check-in). A check-in made + // on the website is not known until the next tap, which then answers "already checked in" and records it. + final checked = + state is CheckinStateChecked || + state is CheckinStateSuccess || + (state is CheckinStateFailed && state.result is CheckinResultAlreadyChecked); + if (checked) { + final checkedTr = context.t.homepage.welcome; + if (label != null) { + return Tooltip( + message: checkedTr.checkedInHint, + child: FilledButton.tonalIcon( + style: FilledButton.styleFrom(minimumSize: const Size(0, 48)), + icon: const Icon(Icons.task_alt_outlined), + label: Text(checkedTr.checkedIn), + onPressed: null, + ), + ); + } + return IconButton(icon: const Icon(Icons.task_alt_outlined), tooltip: checkedTr.checkedIn, onPressed: null); + } + if (state is CheckinStateNeedLogin) { + if (label != null) { + return _buildLabeled(context, icon: const Icon(Icons.domain_verification_outlined), onPressed: null); + } return IconButton(icon: const Icon(Icons.domain_verification_outlined), tooltip: tooltip, onPressed: null); } + if (label != null) { + return _buildLabeled( + context, + icon: const Icon(Icons.domain_verification_outlined), + onPressed: () => context.read().add(const CheckinRequested()), + ); + } + return IconButton( icon: const Icon(Icons.domain_verification_outlined), tooltip: tooltip, diff --git a/lib/features/draft_box/view/draft_box_panel.dart b/lib/features/draft_box/view/draft_box_panel.dart index 681fb26e5..81c171fc7 100644 --- a/lib/features/draft_box/view/draft_box_panel.dart +++ b/lib/features/draft_box/view/draft_box_panel.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; import 'package:tsdm_client/features/draft_box/cubit/draft_cubit.dart'; @@ -13,6 +15,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// The drafts tab of My threads. Its loading failures do not affect other tabs. class DraftBoxPanel extends StatefulWidget { @@ -82,40 +85,83 @@ class _DraftBoxPanelState extends State { bloc: _cubit, builder: (context, state) { final tr = context.t.draftBox; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; return RefreshIndicator( onRefresh: _cubit.load, - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(12), - children: [ - if (state.loginRequired) - Text(tr.loginRequired) - else ...[ - if (state.loading) const LinearProgressIndicator(), - if (state.failed) Padding(padding: const EdgeInsets.all(16), child: Text(tr.loadFailed)), - if (!state.loading && state.entries.isEmpty && !state.failed) - Padding( - padding: const EdgeInsets.all(24), - child: Text(tr.empty, textAlign: TextAlign.center), - ), - for (final entry in state.entries) - Card( - child: ListTile( - leading: const Icon(Icons.edit_note), - title: Text(entry.title), - subtitle: Text(entry.forumName), - trailing: state.opening == entry.tid - ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.chevron_right), + child: AppCenteredList( + builder: (context, horizontal, width) => ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)).add(context.safePadding()), + children: [ + if (state.loginRequired) + AppStateView(icon: Icons.login_outlined, message: tr.loginRequired, scrollable: false) + else ...[ + if (state.loading) ...[ + ClipRRect(borderRadius: BorderRadius.circular(2), child: const LinearProgressIndicator()), + sizedBoxW8H8, + ], + if (state.failed) ...[ + AppNoticeBanner(tone: AppNoticeTone.error, message: tr.loadFailed), + sizedBoxW8H8, + ], + if (!state.loading && state.entries.isEmpty && !state.failed) + AppStateView(icon: Icons.edit_note_outlined, message: tr.empty, scrollable: false), + for (final entry in state.entries) ...[ + AppSurface( onTap: state.loading || state.opening != null ? null : () => _open(entry), + child: Row( + children: [ + const AppIconTile(Icons.edit_note), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + entry.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600), + ), + if (entry.forumName.isNotEmpty) ...[ + sizedBoxW4H4, + Wrap( + children: [AppInfoPill(icon: Icons.forum_outlined, label: entry.forumName)], + ), + ], + ], + ), + ), + sizedBoxW8H8, + if (state.opening == entry.tid) + const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + Icon(Icons.chevron_right, color: colorScheme.outline), + ], + ), ), - ), - if (!state.loading && state.nextPage != null) - TextButton(onPressed: () => _cubit.load(more: true), child: Text(tr.loadMore)) - else if (!state.loading) - TextButton(onPressed: _cubit.load, child: Text(tr.refresh)), + appListSeparator, + ], + if (!state.loading && state.nextPage != null) + Center( + child: TextButton.icon( + icon: const Icon(Icons.expand_more), + onPressed: () => _cubit.load(more: true), + label: Text(tr.loadMore), + ), + ) + else if (!state.loading) + Center( + child: TextButton.icon( + icon: const Icon(Icons.refresh_outlined), + onPressed: _cubit.load, + label: Text(tr.refresh), + ), + ), + ], ], - ], + ), ), ); }, diff --git a/lib/features/editor/widgets/color_bottom_sheet.dart b/lib/features/editor/widgets/color_bottom_sheet.dart index f309275ea..4e29e0c64 100644 --- a/lib/features/editor/widgets/color_bottom_sheet.dart +++ b/lib/features/editor/widgets/color_bottom_sheet.dart @@ -14,6 +14,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/clipboard.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/tips.dart'; /// What type of color is the picker run for. @@ -41,6 +42,41 @@ Future showColorPicker(BuildContext context, Color? initialCol ); } +/// One color to pick: a rounded swatch with a hairline border (so white and transparent-looking colors stay visible), +/// ringed and checked when it is the current color. +class _ColorSwatch extends StatelessWidget { + const _ColorSwatch({required this.color, required this.onTap, this.selected = false}); + + final Color color; + final VoidCallback? onTap; + final bool selected; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final onColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark ? Colors.white : Colors.black; + return Material( + color: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(appInnerRadius), + side: BorderSide( + color: selected ? colorScheme.primary : colorScheme.outlineVariant, + width: selected ? 2.5 : 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: SizedBox( + width: 40, + height: 40, + child: selected ? Icon(Icons.check, size: 18, color: onColor) : null, + ), + ), + ); + } +} + class _ColorBottomSheet extends StatefulWidget { const _ColorBottomSheet(this.initialColor, this.recentColors); @@ -97,10 +133,11 @@ class _ColorBottomSheetState extends State<_ColorBottomSheet> with SingleTickerP Widget _buildNormalTab() { return GridView.builder( shrinkWrap: true, + padding: edgeInsetsT8, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 40, - mainAxisSpacing: 5, - crossAxisSpacing: 5, + maxCrossAxisExtent: 44, + mainAxisSpacing: 8, + crossAxisSpacing: 8, mainAxisExtent: 40, ), itemCount: BBCodeEditorColor.values.length, @@ -109,11 +146,10 @@ class _ColorBottomSheetState extends State<_ColorBottomSheet> with SingleTickerP final color = BBCodeEditorColor.values[index].color; return Tooltip( message: '${BBCodeEditorColor.values[index].name}(${color.hex})', - child: GestureDetector( + child: _ColorSwatch( + color: color, + selected: widget.initialColor?.toARGB32() == color.toARGB32(), onTap: () => Navigator.of(context).pop(PickColorResult.picked(color)), - child: Container( - decoration: BoxDecoration(borderRadius: const BorderRadius.all(Radius.circular(15)), color: color), - ), ), ); }, @@ -195,43 +231,35 @@ class _ColorBottomSheetState extends State<_ColorBottomSheet> with SingleTickerP ), ), sizedBoxW12H12, - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(15)), - color: _customTabColor, - ), - ), + // Preview of the typed value. + _ColorSwatch(color: _customTabColor, onTap: null), ], ), sizedBoxW4H4, Tips(tr.formatTip, enablePadding: false), - sizedBoxW4H4, - Text(tr.recentColor, style: Theme.of(context).textTheme.titleSmall), - sizedBoxW4H4, - Wrap( - spacing: 4, - children: _recentCustomColors - .map( - (e) => GestureDetector( - onTap: () => setState(() { - final value = e.hex.toLowerCase(); - _updateCustomColorPreview(value); - _customColorValueController.text = value; - }), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(15)), + if (_recentCustomColors.isNotEmpty) ...[ + AppSectionHeader(tr.recentColor, icon: Icons.history_outlined), + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _recentCustomColors + .map( + (e) => _ColorSwatch( color: e, + onTap: () => setState(() { + final value = e.hex.toLowerCase(); + _updateCustomColorPreview(value); + _customColorValueController.text = value; + }), ), - ), - ), - ) - .toList(), - ), + ) + .toList(), + ), + ), + ], + sizedBoxW8H8, sizedBoxW4H4, SizedBox( width: sizeButtonInCardMinWidth, @@ -340,9 +368,10 @@ class _ColorBottomSheetState extends State<_ColorBottomSheet> with SingleTickerP ), Align( alignment: Alignment.centerRight, - child: TextButton( + child: TextButton.icon( + icon: const Icon(Icons.format_color_reset_outlined), onPressed: () => Navigator.of(context).pop(PickColorResult.clearColor()), - child: Text(context.t.general.reset), + label: Text(context.t.general.reset), ), ), ], diff --git a/lib/features/editor/widgets/custom_image_tab.dart b/lib/features/editor/widgets/custom_image_tab.dart index 047c0e4e3..3046895cc 100644 --- a/lib/features/editor/widgets/custom_image_tab.dart +++ b/lib/features/editor/widgets/custom_image_tab.dart @@ -7,6 +7,7 @@ import 'package:tsdm_client/shared/providers/storage_provider/models/database/da import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; /// Ask for an image url (or `[img]` code) and an optional name, save it as a sticker (#5). @@ -58,7 +59,9 @@ class _CustomImageAddDialogState extends State<_CustomImageAddDialog> { Widget build(BuildContext context) { final tr = context.t.bbcodeEditor.customImage; return AlertDialog( + icon: const Icon(Icons.add_photo_alternate_outlined), title: Text(tr.addTitle), + scrollable: true, content: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -78,7 +81,7 @@ class _CustomImageAddDialogState extends State<_CustomImageAddDialog> { ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(context.t.general.cancel)), - TextButton(onPressed: _save, child: Text(context.t.general.ok)), + FilledButton(onPressed: _save, child: Text(context.t.general.ok)), ], ); } @@ -105,10 +108,7 @@ class CustomImageTab extends StatelessWidget { if (snapshot.hasData && images.isEmpty) Padding( padding: edgeInsetsL12T4R12B12, - child: Align( - alignment: Alignment.centerLeft, - child: Text(tr.empty, style: Theme.of(context).textTheme.bodySmall), - ), + child: AppNoticeBanner(icon: Icons.collections_outlined, message: tr.empty), ), Flexible( child: GridView.builder( @@ -128,7 +128,7 @@ class CustomImageTab extends StatelessWidget { child: OutlinedButton( // Same square with rounded corners as the thumbnails next to it. style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), padding: EdgeInsets.zero, ), onPressed: () async => showCustomImageAddDialog(context), @@ -140,6 +140,7 @@ class CustomImageTab extends StatelessWidget { return Tooltip( message: image.name.isEmpty ? image.url : image.name, child: InkWell( + borderRadius: BorderRadius.circular(appInnerRadius), onTap: () => Navigator.of(context).pop(customImageBBCode(image.url)), onLongPress: () async { final confirmed = await showQuestionDialog( @@ -153,7 +154,7 @@ class CustomImageTab extends StatelessWidget { } }, child: ClipRRect( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(appInnerRadius), child: CachedImage(image.url, width: 72, height: 72, fit: BoxFit.cover), ), ), diff --git a/lib/features/editor/widgets/editor_frame.dart b/lib/features/editor/widgets/editor_frame.dart new file mode 100644 index 000000000..3ef447319 --- /dev/null +++ b/lib/features/editor/widgets/editor_frame.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// Rounded frame around a rich editor: the writing area of the post editor, the reply bar and the template editor. +/// +/// The border turns to the primary color while [focusNode] has the focus, like an outlined text field. Only the +/// look changes: the editor inside keeps its controller, focus and shortcuts. +class EditorFrame extends StatelessWidget { + /// Constructor. + const EditorFrame({required this.child, this.focusNode, this.padding = edgeInsetsL12T8R12B8, super.key}); + + /// The editor. + final Widget child; + + /// Focus node of the editor, to highlight the frame while writing. + final FocusNode? focusNode; + + /// Padding between the frame and the editor. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + Widget frame({required bool focused}) => DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(appInnerRadius), + border: Border.all( + color: focused ? colorScheme.primary : colorScheme.outlineVariant, + width: focused ? 1.5 : 1, + ), + ), + child: Padding(padding: padding, child: child), + ); + final node = focusNode; + if (node == null) { + return frame(focused: false); + } + return ListenableBuilder( + listenable: node, + builder: (context, _) => frame(focused: node.hasFocus), + ); + } +} + +/// Bar of editor controls under the writing area: a hairline on top, [leading] buttons scroll horizontally when the +/// room is short, [trailing] (send, collapse) stays visible at the end. +class EditorControlBar extends StatelessWidget { + /// Constructor. + const EditorControlBar({ + required this.leading, + this.trailing = const [], + this.padding = const EdgeInsets.fromLTRB(4, 4, 8, 4), + super.key, + }); + + /// Buttons at the start, scrollable. + final List leading; + + /// Buttons at the end, always visible. + final List trailing; + + /// Padding inside the bar, the horizontal room of the page is added by the caller. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + border: Border(top: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6))), + ), + child: Padding( + padding: padding, + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(mainAxisSize: MainAxisSize.min, children: leading), + ), + ), + if (trailing.isNotEmpty) ...[sizedBoxW8H8, ...trailing], + ], + ), + ), + ); + } +} diff --git a/lib/features/editor/widgets/emoji_bottom_sheet.dart b/lib/features/editor/widgets/emoji_bottom_sheet.dart index f45e43b54..25141bf46 100644 --- a/lib/features/editor/widgets/emoji_bottom_sheet.dart +++ b/lib/features/editor/widgets/emoji_bottom_sheet.dart @@ -11,6 +11,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Show a bottom sheet that provides emojis in editor. @@ -69,11 +70,19 @@ class _EmojiBottomSheetState extends State<_EmojiBottomSheet> with SingleTickerP if (data == null) { return Text('${e.id}_${e.emojiList[index].id}'); } - return GestureDetector( - onTap: () async { - Navigator.of(context).pop(e.emojiList[index].code); - }, - child: ClipOval(child: Image.memory(data, fit: BoxFit.cover)), + // Rounded pressed highlight around each emoji, like the swatches of the color picker. + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(appInnerRadius), + onTap: () async { + Navigator.of(context).pop(e.emojiList[index].code); + }, + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipOval(child: Image.memory(data, fit: BoxFit.cover)), + ), + ), ); }, itemCount: e.emojiList.length, @@ -82,7 +91,13 @@ class _EmojiBottomSheetState extends State<_EmojiBottomSheet> with SingleTickerP return Column( children: [ - TabBar(isScrollable: true, tabAlignment: TabAlignment.start, controller: tabController, tabs: tabs.toList()), + TabBar( + isScrollable: true, + tabAlignment: TabAlignment.start, + controller: tabController, + tabs: tabs.toList(), + dividerColor: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.6), + ), sizedBoxW12H12, Expanded( child: TabBarView(controller: tabController, children: [const CustomImageTab(), ...tabViews]), diff --git a/lib/features/editor/widgets/image_dialog.dart b/lib/features/editor/widgets/image_dialog.dart index d1c03f669..133d50ff2 100644 --- a/lib/features/editor/widgets/image_dialog.dart +++ b/lib/features/editor/widgets/image_dialog.dart @@ -14,6 +14,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; import 'package:tsdm_client/shared/providers/image_cache_provider/models/models.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; import 'package:tsdm_client/widgets/tips.dart'; @@ -183,7 +184,13 @@ class _ImageDialogState extends State<_ImageDialog> with LoggerMixin, SingleTick title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(tr.title), + Row( + children: [ + const AppIconTile(Icons.image_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.title)), + ], + ), // TabBar( // controller: tabController, // tabs: [ @@ -205,8 +212,8 @@ class _ImageDialogState extends State<_ImageDialog> with LoggerMixin, SingleTick key: urlForm, child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ IndexedStack( index: index, children: [ @@ -214,95 +221,102 @@ class _ImageDialogState extends State<_ImageDialog> with LoggerMixin, SingleTick // _buildSmmsField(context), ], ), - Row( - children: [ - Expanded( - child: TextFormField( - controller: widthController, - enabled: !autoScaleWidth, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[0-9]+'))], - decoration: InputDecoration( - prefixIcon: const Icon(Icons.horizontal_distribute_outlined), - labelText: tr.width, - ), - validator: (v) { - if (autoScaleWidth) { - return null; - } - if (v == null || v.trim().isEmpty) { - return tr.errorEmpty; - } - final vv = double.tryParse(v); - if (vv == null || vv <= 0) { - return tr.errorInvalidNumber; - } - return null; - }, + // Size: each dimension can be left to the forum ("auto"), only one at a time. + AppInsetBlock( + padding: edgeInsetsL12T12R12B12, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: widthController, + enabled: !autoScaleWidth, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[0-9]+'))], + decoration: InputDecoration( + prefixIcon: const Icon(Icons.horizontal_distribute_outlined), + labelText: tr.width, + ), + validator: (v) { + if (autoScaleWidth) { + return null; + } + if (v == null || v.trim().isEmpty) { + return tr.errorEmpty; + } + final vv = double.tryParse(v); + if (vv == null || vv <= 0) { + return tr.errorInvalidNumber; + } + return null; + }, + ), + ), + sizedBoxW8H8, + FilterChip( + label: Text(tr.auto), + selected: autoScaleWidth, + onSelected: (v) { + setState(() { + if (autoScaleHeight) { + autoScaleHeight = false; + } + autoScaleWidth = v; + }); + }, + ), + ], ), - ), - Checkbox( - value: autoScaleWidth, - onChanged: (v) { - if (v == null) { - return; - } - - setState(() { - if (autoScaleHeight) { - autoScaleHeight = false; - } - autoScaleWidth = v; - }); - }, - ), - Text(tr.auto), - ], - ), - Row( - children: [ - Expanded( - child: TextFormField( - controller: heightController, - enabled: !autoScaleHeight, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[0-9]+'))], - decoration: InputDecoration(prefixIcon: const Icon(Icons.add), labelText: tr.height), - validator: (v) { - if (autoScaleHeight) { - return null; - } - if (v == null || v.trim().isEmpty) { - return tr.errorEmpty; - } - final vv = double.tryParse(v); - if (vv == null || vv <= 0) { - return tr.errorInvalidNumber; - } - return null; - }, + sizedBoxW12H12, + Row( + children: [ + Expanded( + child: TextFormField( + controller: heightController, + enabled: !autoScaleHeight, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[0-9]+'))], + decoration: InputDecoration(prefixIcon: const Icon(Icons.height), labelText: tr.height), + validator: (v) { + if (autoScaleHeight) { + return null; + } + if (v == null || v.trim().isEmpty) { + return tr.errorEmpty; + } + final vv = double.tryParse(v); + if (vv == null || vv <= 0) { + return tr.errorInvalidNumber; + } + return null; + }, + ), + ), + sizedBoxW8H8, + FilterChip( + label: Text(tr.auto), + selected: autoScaleHeight, + onSelected: (v) { + setState(() { + if (autoScaleWidth) { + autoScaleWidth = false; + } + autoScaleHeight = v; + }); + }, + ), + ], ), - ), - Checkbox( - value: autoScaleHeight, - onChanged: (v) { - if (v == null) { - return; - } - - setState(() { - if (autoScaleWidth) { - autoScaleWidth = false; - } - autoScaleHeight = v; - }); - }, - ), - Text(tr.auto), - ], + sizedBoxW8H8, + Tips(tr.autoSingleDirectionSize), + ], + ), ), - Tips(tr.autoSingleDirectionSize), SectionSwitchListTile( + contentPadding: EdgeInsets.zero, title: Text(tr.autoFillSize), subtitle: Text(tr.autoFillSizeDetail), value: autoFillSize, @@ -318,7 +332,7 @@ class _ImageDialogState extends State<_ImageDialog> with LoggerMixin, SingleTick actions: [ if (fillingSize) sizedCircularProgressIndicator, TextButton(child: Text(context.t.general.cancel), onPressed: () => context.pop()), - TextButton( + FilledButton( child: Text(context.t.general.ok), onPressed: () async { if (urlForm.currentState == null || !(urlForm.currentState!).validate()) { diff --git a/lib/features/editor/widgets/mention_picker.dart b/lib/features/editor/widgets/mention_picker.dart index 353f765cc..3b2faf8aa 100644 --- a/lib/features/editor/widgets/mention_picker.dart +++ b/lib/features/editor/widgets/mention_picker.dart @@ -14,6 +14,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/html/adaptive_color.dart'; import 'package:tsdm_client/utils/html/css_parser.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; /// Candidates shared by every picker in the app, so reopening it does not fetch the lists again. @@ -114,19 +115,21 @@ class _MentionPickerSheetState extends State { return Theme.of(context).brightness == Brightness.dark ? color.adaptiveDark() : color; } - Widget _header(String text, {Widget? trailing}) => Padding( - padding: edgeInsetsL16R16.add(edgeInsetsT4B4), - child: Row( - children: [ - Expanded(child: Text(text, style: Theme.of(context).textTheme.labelLarge)), - ?trailing, - ], - ), + Widget _header(String text, {IconData? icon, Widget? trailing}) => AppSectionHeader( + text, + icon: icon, + trailing: trailing, + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), ); Widget _note(String text) => Padding( padding: edgeInsetsL16R16.add(edgeInsetsT4B4), - child: Text(text, style: Theme.of(context).textTheme.bodyMedium), + child: AppInsetBlock( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), ); Widget _friendTile(Friend friend) { @@ -177,6 +180,7 @@ class _MentionPickerSheetState extends State { return [ _header( tr.randomFriend, + icon: Icons.people_outline, trailing: IconButton( icon: const Icon(Icons.refresh), tooltip: tr.refreshRecommendTip, @@ -202,7 +206,10 @@ class _MentionPickerSheetState extends State { if (visible.isEmpty) { return const []; } - return [_header(context.t.bbcodeEditor.userMention.others), ...visible.map(_nameTile)]; + return [ + _header(context.t.bbcodeEditor.userMention.others, icon: Icons.alternate_email), + ...visible.map(_nameTile), + ]; } Widget _useTypedRow(UserMentionState state) { @@ -232,6 +239,11 @@ class _MentionPickerSheetState extends State { hintText: tr.filterHint, prefixIcon: const Icon(Icons.alternate_email), isDense: true, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(appSurfaceRadius), + borderSide: BorderSide.none, + ), ), onChanged: _onFilterChanged, onSubmitted: (v) { diff --git a/lib/features/editor/widgets/toolbar.dart b/lib/features/editor/widgets/toolbar.dart index ce6ff6911..cf848cf07 100644 --- a/lib/features/editor/widgets/toolbar.dart +++ b/lib/features/editor/widgets/toolbar.dart @@ -24,7 +24,7 @@ import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/platform.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; import 'package:tsdm_client/utils/show_toast.dart'; -import 'package:tsdm_client/widgets/tips_card.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Representing all features types. enum EditorFeatures { @@ -181,38 +181,26 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }) async { final tr = context.t.postEditPage.portation; - final topBar = BottomSheetTopBar( - height: 110, - alignment: .center, - child: Padding( - padding: edgeInsetsL12R12, - child: Align( + // The two warnings scroll with the actions (they used to sit in a fixed 110 px top bar that clipped them with a + // large font), then the actions grouped by format: BBCode keeps the content only, Quill Delta the options too. + await showCustomBottomSheet( + title: tr.title, + context: context, + childrenBuilder: (_) => [ + Padding( + padding: edgeInsetsL12R12, child: Column( - spacing: 10, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TipsCard( - iconData: Icons.warning_outlined, - tips: '${tr.tip} ', - color: Theme.of(context).colorScheme.onPrimaryContainer, - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - ), - TipsCard( - tips: '${tr.typesTip} ', - color: Theme.of(context).colorScheme.onSecondaryContainer, - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, - ), + AppNoticeBanner(tone: AppNoticeTone.warning, icon: Icons.warning_outlined, message: tr.tip), + sizedBoxW8H8, + AppNoticeBanner(message: tr.typesTip), + const AppSectionHeader('BBCode', icon: Icons.code_outlined, padding: EdgeInsets.only(top: 16, bottom: 4)), ], ), ), - ), - ); - - await showCustomBottomSheet( - title: tr.title, - topBar: topBar, - context: context, - childrenBuilder: (_) => [ ListTile( + leading: const Icon(Icons.copy_outlined), title: Text(tr.copyBBCode), onTap: () async { await Clipboard.setData(ClipboardData(text: controller.toForumBBCode())); @@ -224,6 +212,7 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.content_paste_outlined), title: Text(tr.pasteBBCode), onTap: () async { final bbcode = (await Clipboard.getData(Clipboard.kTextPlain))?.text; @@ -246,6 +235,7 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.save_alt_outlined), title: Text(tr.exportBBCode), onTap: () async { await _exportFile(context, 'bbcode_', 'txt', controller.toForumBBCode()); @@ -256,6 +246,7 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.file_open_outlined), title: Text(tr.importBBCode), onTap: () async { final data = await _importFile(context, ['txt']); @@ -276,7 +267,16 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { Navigator.of(context).pop(); }, ), + const Padding( + padding: edgeInsetsL12R12, + child: AppSectionHeader( + 'Quill Delta', + icon: Icons.data_object_outlined, + padding: EdgeInsets.only(top: 16, bottom: 4), + ), + ), ListTile( + leading: const Icon(Icons.copy_outlined), title: Text(tr.copyQuilllDelta), onTap: () async { final data = EditorDocument.build(collectMetadata?.call(), controller.toQuillDeltaJson()); @@ -289,6 +289,7 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.content_paste_outlined), title: Text(tr.pasteQuillDelta), onTap: () async { final data = (await Clipboard.getData(Clipboard.kTextPlain))?.text; @@ -323,6 +324,7 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.save_alt_outlined), title: Text(tr.exportQuillDelta), onTap: () async { final data = EditorDocument.build(collectMetadata?.call(), controller.toQuillDeltaJson()); @@ -334,6 +336,7 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.file_open_outlined), title: Text(tr.importQuillDelta), onTap: () async { final data = await _importFile(context, ['json']); @@ -428,11 +431,23 @@ class EditorToolbar extends StatelessWidget with LoggerMixin { showFree: _hasFeature(EditorFeatures.free), ); + // The package builds plain Material icon buttons (IconButton, IconButton.filled while a style is on): give them the + // rounded shape of the app's inner blocks instead of circles, sizes and touch targets unchanged. Only the look + // changes, every button keeps its action and feature switch. + final themed = IconButtonTheme( + data: IconButtonThemeData( + style: IconButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), + ), + child: toolbar, + ); + if (isMobile) { - return Column(mainAxisSize: MainAxisSize.min, children: [toolbar, sizedBoxW24H24]); + return Column(mainAxisSize: MainAxisSize.min, children: [themed, sizedBoxW24H24]); } - return toolbar; + return themed; } } diff --git a/lib/features/editor/widgets/url_dialog.dart b/lib/features/editor/widgets/url_dialog.dart index e8c2c6fa0..b782a9dea 100644 --- a/lib/features/editor/widgets/url_dialog.dart +++ b/lib/features/editor/widgets/url_dialog.dart @@ -9,6 +9,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/clipboard.dart'; import 'package:tsdm_client/widgets/annimate/animated_visibility.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// Link prefix, originally in quill_flutter. @@ -100,12 +101,18 @@ class _UrlDialogState extends State { final tr = context.t.bbcodeEditor.url; return CustomAlertDialog.sync( clipBehavior: Clip.antiAlias, - title: Text(context.t.bbcodeEditor.url.title), + title: Row( + children: [ + const AppIconTile(Icons.link_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(context.t.bbcodeEditor.url.title)), + ], + ), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: descController, @@ -120,53 +127,65 @@ class _UrlDialogState extends State { decoration: InputDecoration(prefixIcon: const Icon(Icons.link_outlined), labelText: tr.link), validator: (v) => v!.trim().isNotEmpty ? null : tr.errorEmpty, ), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - TextButton( - child: Text(tr.autoPaste.action), - onPressed: () async { - final bilibiliText = await getPlainTextFromClipboard(); - if (bilibiliText == null) { - return; - } + // Paste a bilibili share text: a helper, kept apart from the fields. + AppInsetBlock( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: TextButton.icon( + icon: const Icon(Icons.content_paste_outlined), + label: Text(tr.autoPaste.action), + onPressed: () async { + final bilibiliText = await getPlainTextFromClipboard(); + if (bilibiliText == null) { + return; + } - final reMatch = _bilibiliShareRe.firstMatch(bilibiliText); - if (reMatch == null) { - return; - } + final reMatch = _bilibiliShareRe.firstMatch(bilibiliText); + if (reMatch == null) { + return; + } - final desc = reMatch.namedGroup('desc')!; - final url = reMatch.namedGroup('url')!; - setState(() { - descController.text = desc; - urlController.text = url; - }); - }, - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.info_outline), - tooltip: tr.autoPaste.tip, - onPressed: () => setState(() => _bilibiliTipExpanded = !_bilibiliTipExpanded), + final desc = reMatch.namedGroup('desc')!; + final url = reMatch.namedGroup('url')!; + setState(() { + descController.text = desc; + urlController.text = url; + }); + }, + ), + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.info_outline), + tooltip: tr.autoPaste.tip, + isSelected: _bilibiliTipExpanded, + onPressed: () => setState(() => _bilibiliTipExpanded = !_bilibiliTipExpanded), + ), + ], + ), + AnimatedVisibility( + visible: _bilibiliTipExpanded, + duration: duration200, + child: Padding( + padding: edgeInsetsL8R8, + child: Text(tr.autoPaste.detail, style: Theme.of(context).textTheme.labelSmall), ), - ], - ), - AnimatedVisibility( - visible: _bilibiliTipExpanded, - duration: duration200, - child: Text(tr.autoPaste.detail, style: Theme.of(context).textTheme.labelSmall), - ), - ], + ), + ], + ), ), - Row( - mainAxisAlignment: MainAxisAlignment.end, + Wrap( + alignment: WrapAlignment.end, + spacing: 8, children: [ TextButton(child: Text(context.t.general.cancel), onPressed: () => context.pop()), - TextButton( + FilledButton( child: Text(context.t.general.ok), onPressed: () async { if (formKey.currentState == null || !(formKey.currentState!).validate()) { diff --git a/lib/features/favorite/utils/favorite_note_dialog.dart b/lib/features/favorite/utils/favorite_note_dialog.dart index aec442319..5cdc21f43 100644 --- a/lib/features/favorite/utils/favorite_note_dialog.dart +++ b/lib/features/favorite/utils/favorite_note_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Ask the optional note of a new favorite. /// @@ -34,16 +35,28 @@ class _FavoriteNoteDialogState extends State<_FavoriteNoteDialog> { Widget build(BuildContext context) { final tr = context.t.threadPage.favorite; return AlertDialog( + icon: const Icon(Icons.star_outline), title: Text(widget.title ?? tr.add), - content: TextField( - controller: _controller, - autofocus: true, - maxLines: 3, - maxLength: 200, - decoration: InputDecoration(hintText: tr.noteHint, border: const OutlineInputBorder()), + scrollable: true, + content: ConstrainedBox( + // Wide enough for a short note on desktop windows, the dialog insets still apply on phones. + constraints: const BoxConstraints(minWidth: 320), + child: TextField( + controller: _controller, + autofocus: true, + minLines: 2, + maxLines: 4, + maxLength: 200, + decoration: InputDecoration( + hintText: tr.noteHint, + prefixIcon: const Icon(Icons.sticky_note_2_outlined), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), + ), ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.t.general.cancel)), + // Kept a text button: confirmations elsewhere in the app (and their tests) take the last text button as Ok. TextButton( onPressed: () => Navigator.of(context).pop(_controller.text.trim()), child: Text(context.t.general.ok), diff --git a/lib/features/favorite/view/favorite_page.dart b/lib/features/favorite/view/favorite_page.dart index a9ea8dc12..ab97af71d 100644 --- a/lib/features/favorite/view/favorite_page.dart +++ b/lib/features/favorite/view/favorite_page.dart @@ -12,6 +12,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page listing what the current user added to favorites: a tab of threads (帖子) and a tab of forums (版块), like @@ -117,34 +118,32 @@ class _FavoriteTabState extends State<_FavoriteTab> with AutomaticKeepAliveClien padding: edgeInsetsL12T4R12.add(context.safePadding()), children: [ sizedBoxW32H32, - Center( - child: Text( - switch (widget.type) { - FavoriteType.thread => tr.empty, - FavoriteType.forum => tr.emptyForum, - }, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), + AppStateView( + icon: Icons.star_outline, + message: switch (widget.type) { + FavoriteType.thread => tr.empty, + FavoriteType.forum => tr.emptyForum, + }, ), ], ); } - return ListView.separated( - physics: physics, - controller: _scrollController, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.items.length, - itemBuilder: (context, index) { - final item = state.items[index]; - return FavoriteCard( - item, - removing: state.removing.contains(item.favid), - onRemove: () async => _confirmRemove(context, item), - ); - }, - separatorBuilder: (context, index) => sizedBoxW4H4, + return AppCenteredList( + builder: (context, side, _) => ListView.separated( + physics: physics, + controller: _scrollController, + padding: side.copyWith(top: 8).add(context.safePadding()), + itemCount: state.items.length, + itemBuilder: (context, index) { + final item = state.items[index]; + return FavoriteCard( + item, + removing: state.removing.contains(item.favid), + onRemove: () async => _confirmRemove(context, item), + ); + }, + separatorBuilder: (context, index) => appListSeparator, + ), ); }, ); diff --git a/lib/features/favorite/widgets/favorite_card.dart b/lib/features/favorite/widgets/favorite_card.dart index b4e119e61..a4fb95561 100644 --- a/lib/features/favorite/widgets/favorite_card.dart +++ b/lib/features/favorite/widgets/favorite_card.dart @@ -5,9 +5,9 @@ import 'package:tsdm_client/extensions/date_time.dart'; import 'package:tsdm_client/features/favorite/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; -import 'package:tsdm_client/themes/widget_themes.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; -/// Card of one favorite record (thread or forum): title, time, optional note and a remove button. +/// Card of one favorite record (thread or forum): kind icon, title, time pill, optional note and a remove button. class FavoriteCard extends StatelessWidget { /// Constructor. const FavoriteCard(this.item, {required this.onRemove, this.removing = false, super.key}); @@ -37,56 +37,72 @@ class FavoriteCard extends StatelessWidget { Widget build(BuildContext context) { final tr = context.t.favoritePage; final theme = Theme.of(context); - final outline = theme.colorScheme.outline; + final colorScheme = theme.colorScheme; final description = item.description; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async => _open(context), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final isForum = item is FavoriteForum; + return AppSurface( + padding: const EdgeInsets.fromLTRB(12, 12, 4, 12), + onTap: () async => _open(context), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppIconTile(isForum ? Icons.folder_outlined : Icons.bookmark_outline, size: 36), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 6, children: [ - Text(item.title, style: theme.textTheme.titleMedium), - sizedBoxW4H4, - Row( - children: [ - Icon(Icons.access_time_outlined, size: smallIconSize, color: outline), - sizedBoxW4H4, - Text( - item.time == null ? '-' : tr.favoritedAt(time: item.time!.yyyyMMDDHHMM()), - style: theme.textTheme.bodySmall?.copyWith(color: outline), - ), - ], + AppInfoPill( + icon: Icons.access_time_outlined, + label: item.time == null ? '-' : tr.favoritedAt(time: item.time!.yyyyMMDDHHMM()), ), - if (description != null) ...[ - sizedBoxW4H4, - Text( - description, - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.secondary), - ), - ], ], ), - ), - sizedBoxW8H8, - if (removing) - const Padding(padding: edgeInsetsL8R8, child: sizedCircularProgressIndicator) - else - IconButton( - icon: Icon(item is FavoriteForum ? Icons.folder_off_outlined : Icons.bookmark_remove_outlined), - tooltip: tr.remove, - onPressed: onRemove, - ), - ], + if (description != null) ...[ + sizedBoxW8H8, + // The note of the favorite, set on the forum: quoted in an inner block. + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Padding( + padding: edgeInsetsL12T8R12B8, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.format_quote_outlined, size: 16, color: colorScheme.secondary), + sizedBoxW8H8, + Expanded( + child: Text( + description, + style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.secondary), + ), + ), + ], + ), + ), + ), + ], + ], + ), ), - ), + sizedBoxW4H4, + if (removing) + const Padding(padding: edgeInsetsL8R8, child: sizedCircularProgressIndicator) + else + IconButton( + icon: Icon(isForum ? Icons.folder_off_outlined : Icons.bookmark_remove_outlined), + tooltip: tr.remove, + onPressed: onRemove, + ), + ], ), ); } diff --git a/lib/features/forum/view/forum_group_page.dart b/lib/features/forum/view/forum_group_page.dart index 8b84a914b..ef115ce05 100644 --- a/lib/features/forum/view/forum_group_page.dart +++ b/lib/features/forum/view/forum_group_page.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/forum/bloc/forum_group_bloc.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/forum_card.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -27,11 +27,28 @@ class ForumGroupPage extends StatefulWidget { class _ForumGroupPageState extends State { Widget _buildContent(BuildContext context, ForumGroup forumGroup) { - return ListView.separated( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: forumGroup.forumList.length, - itemBuilder: (_, index) => ForumCard(forumGroup.forumList[index]), - separatorBuilder: (_, _) => sizedBoxW4H4, + final forums = forumGroup.forumList; + // One column on phones, two on wide windows; large cards in a wider area on desktop windows, like the topics + // page (phones keep the compact cards at any width). The width is measured inside the page's SafeArea, so side + // insets are already excluded. + return AppCenteredList( + builder: (context, _, width) { + final layout = forumCardListLayout(width, Theme.of(context).platform); + return ListView.separated( + padding: layout.side + .copyWith(top: layout.large ? 16 : 8, bottom: layout.large ? 20 : 0) + .add(context.safePadding()), + itemCount: appRowCount(forums.length, layout.columns), + itemBuilder: (context, row) => AppColumnsRow( + row: row, + columns: layout.columns, + count: forums.length, + gap: layout.gap, + itemBuilder: (_, index) => ForumCard(forums[index], large: layout.large), + ), + separatorBuilder: (_, _) => layout.separator, + ); + }, ); } diff --git a/lib/features/forum/view/forum_page.dart b/lib/features/forum/view/forum_page.dart index 0a524a3a3..6bb62a3d3 100644 --- a/lib/features/forum/view/forum_page.dart +++ b/lib/features/forum/view/forum_page.dart @@ -24,6 +24,7 @@ import 'package:tsdm_client/utils/html/html_muncher.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/error_card.dart'; import 'package:tsdm_client/widgets/card/forum_card.dart'; import 'package:tsdm_client/widgets/card/thread_card/thread_card.dart'; @@ -182,64 +183,82 @@ class _ForumPageState extends State with SingleTickerProviderStateMix ); } + /// Filter bar pinned above the threads: opaque with a hairline under it, the chips start at the edge of the + /// centered column of cards and scroll sideways when they do not fit. Its height follows the chips, so a large font + /// is not clipped. Widget _buildNormalThreadFilterRow(BuildContext context, ForumState state) { - return Row( - children: [ - Expanded( - child: Container( - color: Theme.of(context).colorScheme.surface, - height: 40, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (state.filterTypeList.isNotEmpty) const ThreadTypeChip(), - if (state.filterSpecialTypeList.isNotEmpty) const ThreadSpecialTypeChip(), - if (state.filterDatelineList.isNotEmpty) const ThreadDatelineChip(), - if (state.filterOrderList.isNotEmpty) const ThreadOrderChip(), - const ThreadDigestChip(), - const ThreadRecommendedChip(), - ].prepend(sizedBoxW4H4).insertBetween(sizedBoxW12H12), - ), - ), + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surface, + border: Border(bottom: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6))), + ), + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: appCenteredPadding(constraints.maxWidth).copyWith(top: 4, bottom: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Decorative lead of the bar, every chip carries its own label. + ExcludeSemantics(child: Icon(Icons.tune_outlined, size: 20, color: colorScheme.primary)), + if (state.filterTypeList.isNotEmpty) const ThreadTypeChip(), + if (state.filterSpecialTypeList.isNotEmpty) const ThreadSpecialTypeChip(), + if (state.filterDatelineList.isNotEmpty) const ThreadDatelineChip(), + if (state.filterOrderList.isNotEmpty) const ThreadOrderChip(), + const ThreadDigestChip(), + const ThreadRecommendedChip(), + ].insertBetween(sizedBoxW8H8), ), ), - ], + ), ); } Widget _buildStickThreadTab(BuildContext context, ForumState state) { if (state.stickThreadList.isEmpty && state.rulesElement == null) { - return Center(child: Text(context.t.forumPage.stickThreadTab.noThread)); + return AppStateView(icon: Icons.push_pin_outlined, message: context.t.forumPage.stickThreadTab.noThread); } late final Widget content; if (state.rulesElement == null) { - content = ListView.separated( - controller: _pinnedScrollController, - padding: edgeInsetsL12T4R12, - itemCount: state.stickThreadList.length, - itemBuilder: (context, index) => NormalThreadCard(state.stickThreadList[index]), - separatorBuilder: (context, index) => sizedBoxW4H4, + content = AppCenteredList( + builder: (context, side, _) => ListView.separated( + controller: _pinnedScrollController, + padding: side.copyWith(top: 8).add(context.safePadding()), + itemCount: state.stickThreadList.length, + itemBuilder: (context, index) => NormalThreadCard(state.stickThreadList[index]), + separatorBuilder: (context, index) => appListSeparator, + ), ); } else { - content = ListView.separated( - controller: _pinnedScrollController, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.stickThreadList.length + 1, - itemBuilder: (context, index) { - // TODO: Do NOT add leading rules card by checking index value. - if (index == 0) { - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: munchElement(context, state.rulesElement!), - ); - } else { - return NormalThreadCard(state.stickThreadList[index - 1]); - } - }, - separatorBuilder: (context, index) => sizedBoxW4H4, + content = AppCenteredList( + builder: (context, side, _) => ListView.separated( + controller: _pinnedScrollController, + padding: side.copyWith(top: 8).add(context.safePadding()), + itemCount: state.stickThreadList.length + 1, + itemBuilder: (context, index) { + // TODO: Do NOT add leading rules card by checking index value. + if (index == 0) { + // Forum rules: a titled surface above the pinned threads, the forum's own markup inside. + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + context.t.forumPage.rulesTab.title, + icon: Icons.gavel_outlined, + padding: const EdgeInsets.only(bottom: 8), + ), + munchElement(context, state.rulesElement!), + ], + ), + ); + } else { + return NormalThreadCard(state.stickThreadList[index - 1]); + } + }, + separatorBuilder: (context, index) => appListSeparator, + ), ); } @@ -262,8 +281,9 @@ class _ForumPageState extends State with SingleTickerProviderStateMix // Use _haveNoThread to ensure we parsed the web page and there really // no thread in the forum. if (normalThreadList.isEmpty) { - final emptyContentHint = Center( - child: Text(context.t.forumPage.threadTab.noThread, style: Theme.of(context).inputDecorationTheme.hintStyle), + final emptyContentHint = AppStateView( + icon: Icons.forum_outlined, + message: context.t.forumPage.threadTab.noThread, ); if (state.filterState.isFiltering()) { return Column( @@ -305,20 +325,21 @@ class _ForumPageState extends State with SingleTickerProviderStateMix context.read().add(ForumLoadMoreRequested(state.currentPage + 1)); // _refreshController.finishLoad(); }, - childBuilder: (context, physics) => CustomScrollView( - controller: _threadScrollController, - physics: physics, - slivers: [ - PinnedHeaderSliver(child: _buildNormalThreadFilterRow(context, state)), - const SliverPadding(padding: edgeInsetsL12T4R12), - SliverList.separated( - itemCount: normalThreadList.length, - itemBuilder: (context, index) => - Padding(padding: edgeInsetsL12R12, child: NormalThreadCard(normalThreadList[index])), - separatorBuilder: (context, index) => sizedBoxW4H4, - ), - SliverPadding(padding: context.safePadding()), - ], + childBuilder: (context, physics) => AppCenteredList( + builder: (context, side, _) => CustomScrollView( + controller: _threadScrollController, + physics: physics, + slivers: [ + PinnedHeaderSliver(child: _buildNormalThreadFilterRow(context, state)), + const SliverPadding(padding: edgeInsetsT8), + SliverList.separated( + itemCount: normalThreadList.length, + itemBuilder: (context, index) => Padding(padding: side, child: NormalThreadCard(normalThreadList[index])), + separatorBuilder: (context, index) => appListSeparator, + ), + SliverPadding(padding: context.safePadding()), + ], + ), ), ); } @@ -336,7 +357,7 @@ class _ForumPageState extends State with SingleTickerProviderStateMix if (state.permissionDeniedMessage != null) { return ErrorCard(child: munchElement(context, state.permissionDeniedMessage!)); } else { - return Center(child: Text(context.t.general.noPermission)); + return AppStateView(icon: Icons.lock_outline, message: context.t.general.noPermission); } } else { return TabBarView( @@ -368,7 +389,7 @@ class _ForumPageState extends State with SingleTickerProviderStateMix Widget _buildSubredditTab(BuildContext context, List subredditList) { if (subredditList.isEmpty) { - return Center(child: Text(context.t.forumPage.subredditTab.noSubreddit)); + return AppStateView(icon: Icons.folder_outlined, message: context.t.forumPage.subredditTab.noSubreddit); } return EasyRefresh( @@ -382,12 +403,28 @@ class _ForumPageState extends State with SingleTickerProviderStateMix } context.read().add(ForumRefreshRequested()); }, - child: ListView.separated( - controller: _subredditScrollController, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: subredditList.length, - itemBuilder: (context, index) => ForumCard(subredditList[index]), - separatorBuilder: (context, index) => sizedBoxW4H4, + // One column on phones, two on wide windows; large cards in a wider area on desktop windows, like the topics + // page (phones keep the compact cards at any width). The width is measured inside the page's SafeArea, so side + // insets are already excluded. + child: AppCenteredList( + builder: (context, _, width) { + final layout = forumCardListLayout(width, Theme.of(context).platform); + return ListView.separated( + controller: _subredditScrollController, + padding: layout.side + .copyWith(top: layout.large ? 16 : 8, bottom: layout.large ? 20 : 0) + .add(context.safePadding()), + itemCount: appRowCount(subredditList.length, layout.columns), + itemBuilder: (context, row) => AppColumnsRow( + row: row, + columns: layout.columns, + count: subredditList.length, + gap: layout.gap, + itemBuilder: (_, index) => ForumCard(subredditList[index], large: layout.large), + ), + separatorBuilder: (context, index) => layout.separator, + ); + }, ), ); } @@ -401,26 +438,50 @@ class _ForumPageState extends State with SingleTickerProviderStateMix final pollOfferUid = state.pollOfferUid; final canCreatePoll = pollOfferUid != null && pollOfferUid == context.read().effectiveCurrentUid; - final newThread = FloatingActionButton( - onPressed: () async => context.pushNamed( - ScreenPaths.editPost, - pathParameters: {'editType': '${PostEditType.newThread.index}', 'fid': widget.fid}, - queryParameters: {if (canCreatePoll) 'poll': '1'}, - ), - tooltip: context.t.forumPage.tooltip.fab, - child: const Icon(Icons.add_outlined), + // Wide windows have room for labelled buttons; phones keep the compact round ones over the list. + final wide = MediaQuery.sizeOf(context).width >= 600; + Future openNewThread() async => context.pushNamed( + ScreenPaths.editPost, + pathParameters: {'editType': '${PostEditType.newThread.index}', 'fid': widget.fid}, + queryParameters: {if (canCreatePoll) 'poll': '1'}, ); + Future openPoll() async => context.pushNamed(ScreenPaths.createPoll, pathParameters: {'fid': widget.fid}); + final newThread = wide + ? FloatingActionButton.extended( + onPressed: openNewThread, + tooltip: context.t.forumPage.tooltip.fab, + icon: const Icon(Icons.edit_outlined), + label: Text(context.t.forumPage.tooltip.fab), + ) + : FloatingActionButton( + onPressed: openNewThread, + tooltip: context.t.forumPage.tooltip.fab, + child: const Icon(Icons.edit_outlined), + ); if (!canCreatePoll) return newThread; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ - FloatingActionButton.small( - heroTag: 'forum_create_poll_fab', - onPressed: () async => context.pushNamed(ScreenPaths.createPoll, pathParameters: {'fid': widget.fid}), - tooltip: context.t.pollCreate.entry, - child: const Icon(Icons.poll_outlined), - ), + if (wide) + FloatingActionButton.extended( + heroTag: 'forum_create_poll_fab', + onPressed: openPoll, + tooltip: context.t.pollCreate.entry, + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + icon: const Icon(Icons.poll_outlined), + label: Text(context.t.pollCreate.entry), + ) + else + FloatingActionButton.small( + heroTag: 'forum_create_poll_fab', + onPressed: openPoll, + tooltip: context.t.pollCreate.entry, + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + child: const Icon(Icons.poll_outlined), + ), sizedBoxW12H12, newThread, ], @@ -493,34 +554,43 @@ class _ForumPageState extends State with SingleTickerProviderStateMix ), BlocProvider(create: (context) => JumpPageCubit()), ], - child: BlocBuilder( - builder: (context, state) { - if (state.status == ForumStatus.success && - state.normalThreadList.isEmpty && - // Do not switch tab if filtering but filtering non result left. - !state.filterState.isFiltering()) { - tabController.animateTo(_subredditTabIndex, duration: const Duration(milliseconds: 500)); + // A forum without threads opens its sub forums tab. Switched from a listener, once per loaded state: switching + // in the builder notified the tab listener (setState) during the build, and forced the tab back on every rebuild. + child: BlocListener( + listenWhen: (_, state) => + state.status == ForumStatus.success && + state.normalThreadList.isEmpty && + // Do not switch tab if filtering but filtering non result left. + !state.filterState.isFiltering(), + listener: (context, state) { + if (!mounted || tabController.index == _subredditTabIndex) { + return; } - // Update jump page state. - context.read().setPageInfo(currentPage: state.currentPage, totalPages: state.totalPages); - - // Reset jump page state when every build. - if (state.status == ForumStatus.initial || state.status == ForumStatus.loading) { - context.read().markLoading(); - } else { - context.read().markSuccess(); - } - - return Scaffold( - // appBar: PreferredSize(preferredSize: const Size.fromHeight(145), child: _buildListAppBar(context, state)), - appBar: _buildListAppBar(context, state), - body: NotificationListener( - onNotification: _onBodyScrollNotification, - child: SafeArea(bottom: false, child: _buildBody(context, state)), - ), - floatingActionButton: _buildFloatingActionButton(context, state), - ); + tabController.animateTo(_subredditTabIndex, duration: const Duration(milliseconds: 500)); }, + child: BlocBuilder( + builder: (context, state) { + // Update jump page state. + context.read().setPageInfo(currentPage: state.currentPage, totalPages: state.totalPages); + + // Reset jump page state when every build. + if (state.status == ForumStatus.initial || state.status == ForumStatus.loading) { + context.read().markLoading(); + } else { + context.read().markSuccess(); + } + + return Scaffold( + // appBar: PreferredSize(preferredSize: const Size.fromHeight(145), child: _buildListAppBar(context, state)), + appBar: _buildListAppBar(context, state), + body: NotificationListener( + onNotification: _onBodyScrollNotification, + child: SafeArea(bottom: false, child: _buildBody(context, state)), + ), + floatingActionButton: _buildFloatingActionButton(context, state), + ); + }, + ), ), ); } diff --git a/lib/features/forum/widgets/thread_filter_chip.dart b/lib/features/forum/widgets/thread_filter_chip.dart index 3f28b3203..433d40f9c 100644 --- a/lib/features/forum/widgets/thread_filter_chip.dart +++ b/lib/features/forum/widgets/thread_filter_chip.dart @@ -2,14 +2,29 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/forum/bloc/forum_bloc.dart'; import 'package:tsdm_client/features/forum/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; +/// Widest the option list of a filter sheet grows on wide windows. +const _sheetMaxWidth = 640.0; + +/// One option of a filter sheet. +/// +/// Every option has a radio mark (not only the selected one) so the names stay aligned and the sheet keeps its size +/// when the selection changes. +Widget _filterOption({required String title, required bool selected, required VoidCallback onTap}) => + SelectableListTile( + leading: Icon(selected ? Icons.radio_button_checked : Icons.radio_button_unchecked), + title: Text(title), + selected: selected, + onTap: onTap, + ); + /// Construct a chip that controlling and mutating thread filter state. class ThreadChip extends StatelessWidget { /// Constructor. @@ -38,7 +53,16 @@ class ThreadChip extends StatelessWidget { return BlocBuilder( builder: (context, state) { return FilterChip( - label: Text(chipLabel), + // The chip opens a list of choices: say so with a drop down mark after the current choice. No flexible child: + // the chips sit in a horizontal scroll view and get an unbounded width. + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(chipLabel, maxLines: 1), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + tooltip: sheetTitle, selected: chipSelected, onSelected: state.status.isLoading() ? null @@ -55,7 +79,13 @@ class ThreadChip extends StatelessWidget { value: forumBloc, child: BlocBuilder( builder: (sheetContext, state) => ListView( - padding: sheetContext.safePadding(), + // Centered at a readable width on wide windows; the sheet is as wide as the window, so the + // window width is the room (no layout builder: the sheet measures its content). + padding: appCenteredPadding( + MediaQuery.sizeOf(sheetContext).width, + maxWidth: _sheetMaxWidth, + minPadding: 0, + ).copyWith(top: 4, bottom: 8).add(sheetContext.safePadding()), shrinkWrap: true, children: sheetItemBuilder(sheetContext, state), ), @@ -80,7 +110,7 @@ class ThreadTypeChip extends StatelessWidget { builder: (context, state) { // Show nothing if no filter available. if (state.filterTypeList.isEmpty) { - return sizedBoxEmpty; + return const SizedBox.shrink(); } final currFilter = state.filterState.filterType?.name; @@ -91,8 +121,8 @@ class ThreadTypeChip extends StatelessWidget { sheetTitle: context.t.forumPage.threadTab.threadType, sheetItemBuilder: (context, state) => state.filterTypeList .map( - (e) => SelectableListTile( - title: Text(e.name), + (e) => _filterOption( + title: e.name, selected: e.name == currFilter, onTap: () { context.read().add( @@ -122,7 +152,7 @@ class ThreadSpecialTypeChip extends StatelessWidget { builder: (context, state) { // Show nothing if no special filter available. if (state.filterSpecialTypeList.isEmpty) { - return sizedBoxEmpty; + return const SizedBox.shrink(); } final currFilter = state.filterState.filterSpecialType?.name; @@ -133,9 +163,9 @@ class ThreadSpecialTypeChip extends StatelessWidget { sheetTitle: context.t.forumPage.threadTab.threadSpecialType, sheetItemBuilder: (context, state) => state.filterSpecialTypeList .map( - (e) => SelectableListTile( + (e) => _filterOption( + title: e.name, selected: e.name == currFilter, - title: Text(e.name), onTap: () { context.read().add( ForumChangeThreadFilterStateRequested( @@ -164,7 +194,7 @@ class ThreadDatelineChip extends StatelessWidget { builder: (context, state) { // Show nothing if no dateline filter available. if (state.filterDatelineList.isEmpty) { - return sizedBoxEmpty; + return const SizedBox.shrink(); } final currFilter = state.filterState.filterDateline?.name; @@ -175,8 +205,8 @@ class ThreadDatelineChip extends StatelessWidget { sheetTitle: context.t.forumPage.threadTab.threadDateline, sheetItemBuilder: (context, state) => state.filterDatelineList .map( - (e) => SelectableListTile( - title: Text(e.name), + (e) => _filterOption( + title: e.name, selected: e.name == currFilter, onTap: () { context.read().add( @@ -206,7 +236,7 @@ class ThreadOrderChip extends StatelessWidget { builder: (context, state) { // Show nothing if no order filter available. if (state.filterOrderList.isEmpty) { - return sizedBoxEmpty; + return const SizedBox.shrink(); } final currFilter = state.filterState.filterOrder?.name; @@ -217,8 +247,8 @@ class ThreadOrderChip extends StatelessWidget { sheetTitle: context.t.forumPage.threadTab.threadOrder, sheetItemBuilder: (context, state) => state.filterOrderList .map( - (e) => SelectableListTile( - title: Text(e.name), + (e) => _filterOption( + title: e.name, selected: e.name == currFilter, onTap: () { context.read().add( @@ -247,6 +277,7 @@ class ThreadDigestChip extends StatelessWidget { return BlocBuilder( builder: (context, state) { return FilterChip( + avatar: state.filterState.filterDigest.digest ? null : const Icon(Icons.auto_awesome_outlined), label: Text(context.t.forumPage.threadTab.threadDigested), selected: state.filterState.filterDigest.digest, onSelected: state.status.isLoading() @@ -277,11 +308,10 @@ class ThreadRecommendedChip extends StatelessWidget { return BlocBuilder( builder: (context, state) { return FilterChip( + avatar: state.filterState.filterRecommend.recommend ? null : const Icon(Icons.thumb_up_outlined), label: Text(context.t.forumPage.threadTab.threadRecommended), selected: state.filterState.filterRecommend.recommend, onSelected: state.status.isLoading() - ? null - : state.status.isLoading() ? null : (v) async { context.read().add( diff --git a/lib/features/friend/view/friend_page.dart b/lib/features/friend/view/friend_page.dart index 67c282bcb..2a2e4bdb0 100644 --- a/lib/features/friend/view/friend_page.dart +++ b/lib/features/friend/view/friend_page.dart @@ -12,6 +12,7 @@ import 'package:tsdm_client/features/need_login/view/need_login_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page listing the friends of a user. @@ -42,17 +43,6 @@ class _FriendPageState extends State { super.dispose(); } - Widget _buildCenteredText(BuildContext context, String text) => Center( - child: Padding( - padding: edgeInsetsL24R24, - child: Text( - text, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ), - ); - Widget _buildList(BuildContext context, FriendState state) { final tr = context.t.friendPage; if (!state.refreshing) { @@ -74,24 +64,39 @@ class _FriendPageState extends State { } context.read().add(const FriendLoadMoreRequested()); }, - childBuilder: (context, physics) { - if (state.items.isEmpty) { - return ListView( + childBuilder: (context, physics) => AppCenteredList( + builder: (context, side, width) { + final padding = side.copyWith(top: 12, bottom: 12).add(context.safePadding()); + if (state.items.isEmpty) { + return ListView( + physics: physics, + controller: _scrollController, + padding: padding, + children: [ + sizedBoxW32H32, + AppStateView(icon: Icons.people_outline, message: tr.empty, scrollable: false), + ], + ); + } + // Two columns on wide windows; each row holds up to two friend cards. + final columns = appColumnsFor(width); + final gap = width < 600 ? appSurfaceGapCompact : appSurfaceGap; + return ListView.separated( physics: physics, controller: _scrollController, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - children: [sizedBoxW32H32, _buildCenteredText(context, tr.empty)], + padding: padding, + itemCount: appRowCount(state.items.length, columns), + itemBuilder: (context, row) => AppColumnsRow( + row: row, + columns: columns, + count: state.items.length, + gap: gap, + itemBuilder: (context, index) => FriendCard(state.items[index]), + ), + separatorBuilder: (context, index) => SizedBox(height: gap), ); - } - return ListView.separated( - physics: physics, - controller: _scrollController, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.items.length, - itemBuilder: (context, index) => FriendCard(state.items[index]), - separatorBuilder: (context, index) => sizedBoxW4H4, - ); - }, + }, + ), ); } @@ -102,8 +107,12 @@ class _FriendPageState extends State { needPop: true, popCallback: (context) => context.read().add(const FriendLoadRequested()), ), - FriendStatus.notice => _buildCenteredText(context, state.message ?? ''), - FriendStatus.failure => buildRetryButton(context, () => context.read().add(const FriendLoadRequested())), + // The forum's own explanation (privacy settings, no such member). + FriendStatus.notice => AppStateView(icon: Icons.info_outline, message: state.message ?? ''), + FriendStatus.failure => buildRetryButton( + context, + () => context.read().add(const FriendLoadRequested()), + ), FriendStatus.success => _buildList(context, state), }; @@ -145,12 +154,10 @@ class _FriendPageState extends State { return MultiRepositoryProvider( providers: [RepositoryProvider(create: (_) => const FriendRepository())], child: BlocProvider( - create: (context) => - FriendBloc( - friendRepository: context.repo(), - firstPageUrl: FriendRepository.listUrl(uid: uid, username: widget.username), - ) - ..add(const FriendLoadRequested()), + create: (context) => FriendBloc( + friendRepository: context.repo(), + firstPageUrl: FriendRepository.listUrl(uid: uid, username: widget.username), + )..add(const FriendLoadRequested()), child: BlocListener( listenWhen: (prev, curr) => prev.failureCount != curr.failureCount, listener: (context, state) => showFailedToLoadSnackBar(context), diff --git a/lib/features/friend/widgets/add_friend_dialog.dart b/lib/features/friend/widgets/add_friend_dialog.dart index b0bac0eb3..ce795c7c5 100644 --- a/lib/features/friend/widgets/add_friend_dialog.dart +++ b/lib/features/friend/widgets/add_friend_dialog.dart @@ -5,6 +5,7 @@ import 'package:tsdm_client/features/friend/models/add_friend.dart'; import 'package:tsdm_client/features/friend/repository/friend_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// What the user filled in: the note and the group. typedef AddFriendChoice = ({String note, String gid}); @@ -88,27 +89,52 @@ class _AddFriendDialogState extends State { @override Widget build(BuildContext context) { final tr = context.t.friendPage.addFriend; + final border = OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)); return AlertDialog( scrollable: true, - title: Text(tr.title(name: widget.username)), + title: Row( + children: [ + const AppIconTile(Icons.person_add_alt_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.title(name: widget.username))), + ], + ), content: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: note, autofocus: true, maxLength: addFriendNoteMaxLength, - decoration: InputDecoration(labelText: tr.note, helperText: widget.form.noteHint), + decoration: InputDecoration( + labelText: tr.note, + helperText: widget.form.noteHint, + helperMaxLines: 3, + prefixIcon: const Icon(Icons.edit_note_outlined), + filled: true, + border: border, + ), onSubmitted: (_) => _submit(), ), if (widget.form.groups.isNotEmpty) ...[ - sizedBoxW4H4, + sizedBoxW8H8, DropdownButtonFormField( initialValue: gid, - decoration: InputDecoration(labelText: tr.group), + isExpanded: true, + borderRadius: BorderRadius.circular(appInnerRadius), + decoration: InputDecoration( + labelText: tr.group, + prefixIcon: const Icon(Icons.group_outlined), + filled: true, + border: border, + ), items: [ - for (final group in widget.form.groups) DropdownMenuItem(value: group.gid, child: Text(group.name)), + for (final group in widget.form.groups) + DropdownMenuItem( + value: group.gid, + child: Text(group.name, overflow: TextOverflow.ellipsis), + ), ], onChanged: (v) => setState(() => gid = v ?? gid), ), @@ -117,7 +143,7 @@ class _AddFriendDialogState extends State { ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.t.general.cancel)), - FilledButton(onPressed: _submit, child: Text(tr.send)), + FilledButton.icon(onPressed: _submit, icon: const Icon(Icons.send_outlined), label: Text(tr.send)), ], ); } diff --git a/lib/features/friend/widgets/approve_friend_dialog.dart b/lib/features/friend/widgets/approve_friend_dialog.dart index 4440d3b39..40e6706ab 100644 --- a/lib/features/friend/widgets/approve_friend_dialog.dart +++ b/lib/features/friend/widgets/approve_friend_dialog.dart @@ -12,6 +12,7 @@ import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Localized text of [failure]. String approveFriendFailureText(BuildContext context, ApproveFriendFailure failure) { @@ -260,15 +261,32 @@ class _ApproveFriendDialogState extends State with LoggerMi final name = form.targetName.isEmpty ? 'UID ${form.targetUid}' : form.targetName; content = Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(tr.content(name: name)), + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Text(tr.content(name: name)), + ), sizedBoxW12H12, DropdownButtonFormField( key: const ValueKey('approve-friend-group'), initialValue: _gid, - decoration: InputDecoration(labelText: tr.group), - items: [for (final group in form.groups) DropdownMenuItem(value: group.gid, child: Text(group.name))], + isExpanded: true, + borderRadius: BorderRadius.circular(appInnerRadius), + decoration: InputDecoration( + labelText: tr.group, + prefixIcon: const Icon(Icons.group_outlined), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), + items: [ + for (final group in form.groups) + DropdownMenuItem( + value: group.gid, + child: Text(group.name, overflow: TextOverflow.ellipsis), + ), + ], onChanged: _submitting ? null : (v) => setState(() => _gid = v ?? _gid), ), if (_submitting) ...[ @@ -294,7 +312,13 @@ class _ApproveFriendDialogState extends State with LoggerMi }, child: AlertDialog( scrollable: true, - title: Text(tr.title), + title: Row( + children: [ + const AppIconTile(Icons.how_to_reg_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.title)), + ], + ), content: content, actions: [ TextButton( diff --git a/lib/features/friend/widgets/friend_card.dart b/lib/features/friend/widgets/friend_card.dart index 371f9b52b..59c22a77e 100644 --- a/lib/features/friend/widgets/friend_card.dart +++ b/lib/features/friend/widgets/friend_card.dart @@ -6,6 +6,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/html/adaptive_color.dart'; import 'package:tsdm_client/utils/html/css_parser.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/heroes.dart'; @@ -41,80 +42,84 @@ class FriendCard extends StatelessWidget { final groupIconUrl = friend.groupIconUrl; final credits = friend.credits; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': friend.uid}), - child: Padding( - padding: edgeInsetsL12T8R12B8, - child: Row( - children: [ - HeroUserAvatar( - username: friend.username, - avatarUrl: friend.avatarUrl, - minRadius: _avatarRadius, - maxRadius: _avatarRadius, - disableHero: true, - ), - sizedBoxW12H12, - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return AppSurface( + padding: edgeInsetsL12T12R12B12, + onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': friend.uid}), + child: Row( + children: [ + HeroUserAvatar( + username: friend.username, + avatarUrl: friend.avatarUrl, + minRadius: _avatarRadius, + maxRadius: _avatarRadius, + disableHero: true, + ), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + friend.username, + style: theme.textTheme.titleMedium?.copyWith( + color: _color(context, friend.nameColor), + fontWeight: FontWeight.bold, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + sizedBoxW4H4, + // Group and credits; a long group name pushes the credits to the next line instead of being cut. + Wrap( + spacing: 8, + runSpacing: 2, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Text( - friend.username, - style: theme.textTheme.titleMedium?.copyWith(color: _color(context, friend.nameColor)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - sizedBoxW4H4, - // Group and credits; a long group name pushes the credits to the next line instead of being cut. - Wrap( - spacing: 8, - runSpacing: 2, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (groupIconUrl != null || groupName != null) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (groupIconUrl != null) ...[ - CachedImage(groupIconUrl, height: _groupIconHeight, maxWidth: 64), - sizedBoxW4H4, - ], - if (groupName != null) - Flexible( - child: Text( - groupName, - style: secondaryStyle?.copyWith( - color: _color(context, friend.groupColor) ?? secondaryStyle.color, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + if (groupIconUrl != null || groupName != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (groupIconUrl != null) ...[ + CachedImage(groupIconUrl, height: _groupIconHeight, maxWidth: 64), + sizedBoxW4H4, + ], + if (groupName != null) + Flexible( + child: Text( + groupName, + style: secondaryStyle?.copyWith( + color: _color(context, friend.groupColor) ?? secondaryStyle.color, ), - ], - ), - if (credits != null) Text(tr.credits(count: credits), style: secondaryStyle), - ], - ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (credits != null) + AppInfoPill( + icon: Icons.stars_outlined, + label: tr.credits(count: credits), + ), ], ), - ), - sizedBoxW8H8, - IconButton( - icon: const Icon(Icons.email_outlined), - tooltip: tr.sendMessage, - onPressed: () async => context.pushNamed( - ScreenPaths.chat, - pathParameters: {'uid': friend.uid}, - extra: {'username': friend.username}, - ), - ), - ], + ], + ), + ), + sizedBoxW8H8, + IconButton.filledTonal( + icon: const Icon(Icons.email_outlined), + tooltip: tr.sendMessage, + style: IconButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), + onPressed: () async => context.pushNamed( + ScreenPaths.chat, + pathParameters: {'uid': friend.uid}, + extra: {'username': friend.username}, + ), ), - ), + ], ), ); } diff --git a/lib/features/friend/widgets/friend_picker_sheet.dart b/lib/features/friend/widgets/friend_picker_sheet.dart index eefe35d39..a142d4a20 100644 --- a/lib/features/friend/widgets/friend_picker_sheet.dart +++ b/lib/features/friend/widgets/friend_picker_sheet.dart @@ -12,6 +12,7 @@ import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; /// Let the user pick one of their friends; returns null when dismissed (GitHub #23). @@ -84,28 +85,39 @@ class _FriendPickerSheetState extends State { final visible = friends?.where((e) => e.username.toLowerCase().contains(_keyword.toLowerCase())).toList(); final Widget body; if (_failed) { - body = Center( - child: TextButton(onPressed: _load, child: Text(context.t.general.failedToLoad)), + body = AppStateView( + icon: Icons.cloud_off_outlined, + message: context.t.general.failedToLoad, + error: true, + action: TextButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: Text(context.t.general.retry), + ), ); } else if (friends == null) { - body = const LinearProgressIndicator(); + body = const Padding(padding: edgeInsetsL12R12, child: LinearProgressIndicator()); } else if (friends.isEmpty) { - body = Padding( - padding: edgeInsetsL12T12R12B12, - child: Text(_message != null ? tr.friendsUnavailable(message: _message!) : tr.noFriends), + body = AppStateView( + icon: Icons.people_outline, + message: _message != null ? tr.friendsUnavailable(message: _message!) : tr.noFriends, ); } else if (visible!.isEmpty) { - body = Padding(padding: edgeInsetsL12T12R12B12, child: Text(tr.noMatch)); + body = AppStateView(icon: Icons.search_off_outlined, message: tr.noMatch); } else { + final colorScheme = Theme.of(context).colorScheme; body = ListView.builder( shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: visible.length, itemBuilder: (context, index) { final friend = visible[index]; return ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), leading: HeroUserAvatar(username: friend.username, avatarUrl: friend.avatarUrl, disableHero: true), - title: Text(friend.username), + title: Text(friend.username, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: friend.groupName == null ? null : Text(friend.groupName!), + trailing: Icon(Icons.chevron_right, color: colorScheme.outline), onTap: () => Navigator.of(context).pop(friend), ); }, @@ -117,11 +129,21 @@ class _FriendPickerSheetState extends State { Padding( padding: edgeInsetsL12T4R12, child: TextField( - decoration: InputDecoration(prefixIcon: const Icon(Icons.search_outlined), hintText: tr.filterHint), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search_outlined), + hintText: tr.filterHint, + filled: true, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(appInnerRadius), + borderSide: BorderSide.none, + ), + ), + textInputAction: TextInputAction.search, onChanged: (v) => setState(() => _keyword = v.trim()), ), ), - sizedBoxW4H4, + sizedBoxW8H8, Flexible(child: body), ], ); diff --git a/lib/features/home/view/home_page.dart b/lib/features/home/view/home_page.dart index aa4570259..a5e67da76 100644 --- a/lib/features/home/view/home_page.dart +++ b/lib/features/home/view/home_page.dart @@ -19,6 +19,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/app_routes.dart'; import 'package:tsdm_client/routes/page_stack.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; const _drawerWidth = 250.0; @@ -107,31 +108,51 @@ class _HomePageState extends State with LoggerMixin { Widget _buildDrawerBody(BuildContext context) => Scaffold( body: Row( children: [ - if (widget.showNavigationBar) - Column( - children: [ - Container( - color: Theme.of(context).colorScheme.surface, - height: 100, - width: _drawerWidth, - child: Center( - child: Text( - context.t.appName, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.secondary, + if (widget.showNavigationBar) ...[ + // One side panel: the brand block and the drawer share the low container color of the navigation. + ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Column( + children: [ + // Brand block of the side navigation, in the rounded style of the homepage cards. + SizedBox( + height: 100, + width: _drawerWidth, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 8), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(appSurfaceRadius), + ), + child: Center( + child: Padding( + padding: edgeInsetsL12R12, + child: Text( + context.t.appName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ), ), ), ), - ), - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _drawerWidth), - child: const HomeNavigationDrawer(), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _drawerWidth), + child: const HomeNavigationDrawer(), + ), ), - ), - ], + ], + ), ), + const HomeNavigationDivider(), + ], Expanded(child: widget.child), ], ), @@ -180,7 +201,10 @@ class _HomePageState extends State with LoggerMixin { return Scaffold( body: Row( children: [ - if (widget.showNavigationBar) const HomeNavigationRail(), + if (widget.showNavigationBar) ...[ + const HomeNavigationRail(), + const HomeNavigationDivider(), + ], Expanded(child: widget.child), ], ), diff --git a/lib/features/home/widgets/home_navigation_bar.dart b/lib/features/home/widgets/home_navigation_bar.dart index 589b87e68..6c9451b44 100644 --- a/lib/features/home/widgets/home_navigation_bar.dart +++ b/lib/features/home/widgets/home_navigation_bar.dart @@ -16,14 +16,27 @@ class _HomeNavigationBarState extends State { @override Widget build(BuildContext context) { - final barItems = _buildNavigationItems(context); + // Only the three tabs: the other side navigation entries stay reachable from the homepage (greeting actions, tools + // card, user menu, notice button) instead of crowding the bar with nine destinations. + final barItems = _buildTabItems(context); + final colorScheme = Theme.of(context).colorScheme; - return NavigationBar( - destinations: barItems - .map((e) => NavigationDestination(icon: e.icon, selectedIcon: e.selectedIcon, label: e.label)) - .toList(), - selectedIndex: context.watch().state.tab.index, - onDestinationSelected: (index) => _onHomeDestinationSelected(context, _doubleTap, barItems, index), + // Same look as the side navigation: low container color, rounded indicator, hairline towards the content. + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: _navigationHairline(colorScheme))), + ), + child: NavigationBar( + backgroundColor: colorScheme.surfaceContainerLow, + elevation: 0, + indicatorShape: _navigationIndicatorShape, + labelBehavior: NavigationDestinationLabelBehavior.alwaysShow, + destinations: barItems + .map((e) => NavigationDestination(icon: e.icon, selectedIcon: e.selectedIcon, label: e.label)) + .toList(), + selectedIndex: _selectedIndexOf(barItems, context.watch().state.tab), + onDestinationSelected: (index) => _onHomeDestinationSelected(context, _doubleTap, barItems, index), + ), ); } } diff --git a/lib/features/home/widgets/home_navigation_drawer.dart b/lib/features/home/widgets/home_navigation_drawer.dart index 5bec3b39c..65a1d7786 100644 --- a/lib/features/home/widgets/home_navigation_drawer.dart +++ b/lib/features/home/widgets/home_navigation_drawer.dart @@ -17,12 +17,37 @@ class _HomeNavigationDrawerState extends State { @override Widget build(BuildContext context) { final barItems = _buildNavigationItems(context); + final colorScheme = Theme.of(context).colorScheme; + // Same look as the bottom bar and the rail: low container color (shared with the brand block above it), rounded + // indicator. + // + // Grouped like the reference sidebar: the main entries, then the "more" entries under their header. The header is + // not a destination, so the indices of onDestinationSelected and selectedIndex stay those of barItems. The drawer + // is a list and scrolls when the window is short. return NavigationDrawer( - selectedIndex: context.watch().state.tab.index, + backgroundColor: colorScheme.surfaceContainerLow, + elevation: 0, + indicatorShape: _navigationIndicatorShape, + selectedIndex: _selectedIndexOf(barItems, context.watch().state.tab), onDestinationSelected: (index) => _onHomeDestinationSelected(context, _doubleTap, barItems, index), - children: barItems - .map((e) => NavigationDrawerDestination(icon: e.icon, selectedIcon: e.selectedIcon, label: Text(e.label))) - .toList(), + children: [ + for (final (i, e) in barItems.indexed) ...[ + if (i > 0 && e.group != barItems[i - 1].group) _NavigationSectionHeader(context.t.navigation.moreSection), + NavigationDrawerDestination( + icon: _destinationIcon(e), + selectedIcon: _destinationIcon(e, selected: true), + // The label sits in a row of unbounded width: bound it so a long label (large text) is cut instead of + // overflowing the 250px side panel. + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _drawerLabelMaxWidth), + child: Text(e.label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ), + ], + ], ); } } + +/// Room of a drawer label in the 250px side panel, after the tile padding, the icon and the gaps. +const _drawerLabelMaxWidth = 150.0; diff --git a/lib/features/home/widgets/home_navigation_rail.dart b/lib/features/home/widgets/home_navigation_rail.dart index b584e52c8..1fafb5851 100644 --- a/lib/features/home/widgets/home_navigation_rail.dart +++ b/lib/features/home/widgets/home_navigation_rail.dart @@ -17,14 +17,61 @@ class _HomeNavigationRailState extends State { @override Widget build(BuildContext context) { final barItems = _buildNavigationItems(context); + final colorScheme = Theme.of(context).colorScheme; - return NavigationRail( - groupAlignment: 0, - destinations: barItems - .map((e) => NavigationRailDestination(icon: e.icon, selectedIcon: e.selectedIcon, label: Text(e.label))) - .toList(), - selectedIndex: context.watch().state.tab.index, + // Same look as the bottom bar and the drawer: low container color, rounded indicator, labels always shown. + final rail = NavigationRail( + groupAlignment: -1, + backgroundColor: colorScheme.surfaceContainerLow, + indicatorShape: _navigationIndicatorShape, + labelType: NavigationRailLabelType.all, + destinations: [ + for (final (i, e) in barItems.indexed) + NavigationRailDestination( + icon: _destinationIcon(e), + selectedIcon: _destinationIcon(e, selected: true), + label: Text(e.label, maxLines: 1, overflow: TextOverflow.ellipsis), + // A gap marks the "more" group: the rail has no room for the section header of the drawer. + padding: i > 0 && e.group != barItems[i - 1].group ? const EdgeInsets.only(top: _railGroupGap) : null, + ), + ], + selectedIndex: _selectedIndexOf(barItems, context.watch().state.tab), onDestinationSelected: (index) => _onHomeDestinationSelected(context, _doubleTap, barItems, index), ); + + // NavigationRail does not scroll: nine destinations overflow a short window (landscape phone, small tablet, large + // text). It gets the window height, or the height its destinations really need when that is more, inside a scroll + // view. The height is measured (intrinsic height of the rail), not estimated: a generous estimate left empty room + // at the end, and the last destination stopped half clipped at the top of the scroll area (feedback 110). + // + // The top and bottom insets (status bar, gesture bar) stay outside of the scroll view as bands of the rail color: + // inside, the rail padded them in its own content and scrolling to the end moved the destinations under the status + // bar (feedback 110). The scroll view clips to the area between the bands; the start inset (cutout in landscape) + // is still handled by the rail itself. + final padding = MediaQuery.paddingOf(context); + return ColoredBox( + color: colorScheme.surfaceContainerLow, + child: Padding( + padding: EdgeInsets.only(top: padding.top, bottom: padding.bottom), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + key: const ValueKey('home-navigation-rail-scroll'), + primary: false, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.hasBoundedHeight ? constraints.maxHeight : 0), + child: IntrinsicHeight(child: rail), + ), + ), + ), + ), + ), + ); } } + +/// Gap before the first destination of the "more" group in the rail. +const _railGroupGap = 16.0; diff --git a/lib/features/home/widgets/home_tab_tap.dart b/lib/features/home/widgets/home_tab_tap.dart index 3493cee52..1f454cbed 100644 --- a/lib/features/home/widgets/home_tab_tap.dart +++ b/lib/features/home/widgets/home_tab_tap.dart @@ -33,18 +33,34 @@ final class HomeTabDoubleTapDetector { /// Handle a tap on destination [index] of [items]. /// -/// A double tap asks the page of that tab to scroll to top (or refresh when already there) through -/// [scrollToTopStream] instead of routing a second time; any other tap switches to the tab. +/// Tabs: a double tap asks the page of that tab to scroll to top (or refresh when already there) through +/// [scrollToTopStream] with the index of the [HomeTab], instead of routing a second time; any other tap switches to the +/// tab. +/// +/// Other destinations push their page above the shell (the back button returns to the selected tab); the second tap of +/// a double tap is dropped so a quick double tap opens one page, not two. A page needing an account opens the login page +/// when there is none. void _onHomeDestinationSelected( BuildContext context, HomeTabDoubleTapDetector detector, List<_NavigationItem> items, int index, ) { - if (detector.tap(index)) { - scrollToTopStream.add(ScrollToTopEvent(index)); + final item = items[index]; + final doubleTap = detector.tap(index); + final tab = item.tab; + if (tab == null) { + if (doubleTap) { + return; + } + final loggedIn = context.readOrNull()?.currentUser != null; + unawaited(context.pushNamed(item.needLogin && !loggedIn ? ScreenPaths.login : item.targetPath)); + return; + } + if (doubleTap) { + scrollToTopStream.add(ScrollToTopEvent(tab.index)); return; } - context.read().setTab(items[index].tab); - context.goNamed(items[index].targetPath); + context.read().setTab(tab); + context.goNamed(item.targetPath); } diff --git a/lib/features/home/widgets/widgets.dart b/lib/features/home/widgets/widgets.dart index eef5decaf..f7f2a64d0 100644 --- a/lib/features/home/widgets/widgets.dart +++ b/lib/features/home/widgets/widgets.dart @@ -1,17 +1,56 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/home/cubit/home_cubit.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_state_cubit.dart'; import 'package:tsdm_client/features/root/stream/scroll_to_top_stream.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; part 'home_navigation_bar.dart'; part 'home_navigation_drawer.dart'; part 'home_navigation_rail.dart'; part 'home_tab_tap.dart'; +/// Indicator of the selected destination in the bottom bar, the rail and the drawer: the inner radius of the app +/// surfaces instead of the default pill. +const _navigationIndicatorShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(appInnerRadius)), +); + +/// Hairline between the navigation and the content, the border color of the app surfaces. +Color _navigationHairline(ColorScheme colorScheme) => colorScheme.outlineVariant.withValues(alpha: 0.6); + +/// Divider between the side navigation (rail or drawer) and the content. +class HomeNavigationDivider extends StatelessWidget { + /// Constructor. + const HomeNavigationDivider({super.key}); + + @override + Widget build(BuildContext context) => + VerticalDivider(width: 1, thickness: 1, color: _navigationHairline(Theme.of(context).colorScheme)); +} + +/// Group of a destination in the side navigation. +enum _NavigationGroup { + /// Main entries at the top: home, forums, activities, notifications, profile. + main, + + /// "More" entries under their section header: title shop, medal center, bank, settings. + more, +} + /// Bar item in app navigator. +/// +/// Items with a [tab] switch the home shell branch (their state is kept, a double tap scrolls them to top); the others +/// open their page above the shell with `pushNamed`, the back button returns to the tab that was selected. final class _NavigationItem { /// Constructor. const _NavigationItem({ @@ -19,7 +58,10 @@ final class _NavigationItem { required this.selectedIcon, required this.label, required this.targetPath, - required this.tab, + this.tab, + this.group = _NavigationGroup.main, + this.unreadNoticeBadge = false, + this.needLogin = false, }); /// Item icon. @@ -38,31 +80,160 @@ final class _NavigationItem { /// Screen path of the item. final String targetPath; - /// Tab index. - final HomeTab tab; + /// Tab of the home shell, null for a page opened above the shell. + final HomeTab? tab; + + /// Section of the side navigation. + final _NavigationGroup group; + + /// Show the unread notification count (`NoticeIcon` rules) on the icon. + final bool unreadNoticeBadge; + + /// The page cannot be used without an account: open the login page instead when there is none. + final bool needLogin; +} + +/// The three home shell tabs, the destinations of the phone navigation bar. +List<_NavigationItem> _buildTabItems(BuildContext context) => + _buildNavigationItems(context).where((e) => e.tab != null).toList(); + +/// All side navigation items (rail and drawer), in display order. +/// +/// Every tab of [HomeTab] appears exactly once. +List<_NavigationItem> _buildNavigationItems(BuildContext context) { + final tr = context.t.navigation; + return [ + _NavigationItem( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: tr.homepage, + targetPath: ScreenPaths.homepage, + tab: HomeTab.home, + ), + _NavigationItem( + icon: const Icon(Icons.topic_outlined), + selectedIcon: const Icon(Icons.topic), + label: tr.topics, + targetPath: ScreenPaths.topic, + tab: HomeTab.topic, + ), + _NavigationItem( + icon: const Icon(Icons.event_outlined), + selectedIcon: const Icon(Icons.event), + label: tr.activities, + targetPath: ScreenPaths.activities, + ), + _NavigationItem( + icon: const Icon(Icons.notifications_outlined), + selectedIcon: const Icon(Icons.notifications), + label: tr.notice, + targetPath: ScreenPaths.notice, + unreadNoticeBadge: true, + // Same rule as the notice button of the app bar, which is disabled without an account. + needLogin: true, + ), + _NavigationItem( + icon: const Icon(Icons.person_outline), + selectedIcon: const Icon(Icons.person), + label: tr.profile, + // The profile page shows its own "need login" state. + targetPath: ScreenPaths.loggedUserProfile, + ), + _NavigationItem( + icon: const Icon(Icons.sell_outlined), + selectedIcon: const Icon(Icons.sell), + label: tr.titleShop, + targetPath: ScreenPaths.titleShop, + group: _NavigationGroup.more, + ), + _NavigationItem( + icon: const Icon(Icons.military_tech_outlined), + selectedIcon: const Icon(Icons.military_tech), + label: tr.medalCenter, + targetPath: ScreenPaths.medalCenter, + group: _NavigationGroup.more, + ), + _NavigationItem( + icon: const Icon(Icons.account_balance_outlined), + selectedIcon: const Icon(Icons.account_balance), + label: tr.bank, + targetPath: ScreenPaths.bank, + group: _NavigationGroup.more, + ), + _NavigationItem( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: tr.settings, + targetPath: ScreenPaths.settings.path, + tab: HomeTab.settings, + group: _NavigationGroup.more, + ), + ]; +} + +/// Index in [items] of the destination showing [tab]: the selected one. The pages opened above the shell cover the +/// navigation, so only tabs are ever selected. +int _selectedIndexOf(List<_NavigationItem> items, HomeTab tab) => items.indexWhere((e) => e.tab == tab); + +/// Icon of [item], with the unread notification count when it carries one. +Widget _destinationIcon(_NavigationItem item, {bool selected = false}) { + final icon = selected ? item.selectedIcon : item.icon; + return item.unreadNoticeBadge ? _UnreadNoticeIcon(icon) : icon; +} + +/// [icon] with the unread notification count, by the rules of `NoticeIcon`: only with an account, only when the unread +/// hint setting is on and only when something is unread. The count is the one of [NotificationStateCubit], which the +/// notification sync keeps (blocked and muted senders already left out); nothing is shown without that cubit. +class _UnreadNoticeIcon extends StatelessWidget { + const _UnreadNoticeIcon(this.icon); + + final Icon icon; + + @override + Widget build(BuildContext context) { + final cubit = context.readOrNull(); + if (cubit == null) { + return icon; + } + return BlocBuilder( + bloc: cubit, + builder: (context, state) { + final loggedIn = context.readOrNull()?.currentUser != null; + final showUnreadHint = + getIt.isRegistered() && + getIt.get().currentSettings.showUnreadInfoHint; + if (!loggedIn || !showUnreadHint || state.total <= 0) { + return icon; + } + final colorScheme = Theme.of(context).colorScheme; + return Badge( + label: Text('${state.total}'), + backgroundColor: colorScheme.error, + textColor: colorScheme.onError, + child: icon, + ); + }, + ); + } } -/// All navigation bar items. -List<_NavigationItem> _buildNavigationItems(BuildContext context) => [ - _NavigationItem( - icon: const Icon(Icons.home_outlined), - selectedIcon: const Icon(Icons.home), - label: context.t.navigation.homepage, - targetPath: ScreenPaths.homepage, - tab: HomeTab.home, - ), - _NavigationItem( - icon: const Icon(Icons.topic_outlined), - selectedIcon: const Icon(Icons.topic), - label: context.t.navigation.topics, - targetPath: ScreenPaths.topic, - tab: HomeTab.topic, - ), - _NavigationItem( - icon: const Icon(Icons.settings_outlined), - selectedIcon: const Icon(Icons.settings), - label: context.t.navigation.settings, - targetPath: ScreenPaths.settings.path, - tab: HomeTab.settings, - ), -]; +/// Section header "More" of the side navigation. +class _NavigationSectionHeader extends StatelessWidget { + const _NavigationSectionHeader(this.label); + + final String label; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.fromLTRB(28, 16, 16, 8), + child: Semantics( + header: true, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ); +} diff --git a/lib/features/homepage/bloc/homepage_bloc.dart b/lib/features/homepage/bloc/homepage_bloc.dart index eb3fcbfae..7a1de4820 100644 --- a/lib/features/homepage/bloc/homepage_bloc.dart +++ b/lib/features/homepage/bloc/homepage_bloc.dart @@ -11,6 +11,7 @@ import 'package:tsdm_client/extensions/fp.dart'; import 'package:tsdm_client/extensions/universal_html.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/authentication/utils/logged_user_parser.dart'; import 'package:tsdm_client/features/homepage/models/models.dart'; import 'package:tsdm_client/features/profile/repository/profile_repository.dart'; import 'package:tsdm_client/features/profile/utils/parse_profile.dart'; @@ -54,6 +55,7 @@ class HomepageBloc extends Bloc with LoggerMixin { ) { on(_onHomepageLoadRequested); on(_onHomepageRefreshRequested); + on(_onDailyRedPacketCheckRequested); on(_onHomepageAuthChanged); on(_onHomepagePauseSwiper); on(_onHomepageResumeSwiper); @@ -74,6 +76,10 @@ class HomepageBloc extends Bloc with LoggerMixin { final AuthenticationRepository _authenticationRepository; late final StreamSubscription> _authStatusSub; + /// Bumped whenever the whole page is loaded again or the account changes; a daily red packet check that finishes + /// under another generation is stale and dropped. + int _pageGeneration = 0; + /// Build the swiper picture list from the `Kahrpba` plugin block. /// /// The plugin is gone since Discuz X5 so the node is usually null, returns an empty list silently. @@ -158,6 +164,7 @@ class HomepageBloc extends Bloc with LoggerMixin { } Future _onHomepageLoadRequested(HomepageLoadRequested event, Emitter emit) async { + _pageGeneration++; if (_forumHomeRepository.hasCache()) { final s = _parseStateFromDocument( _forumHomeRepository.getCache()!, @@ -209,6 +216,7 @@ class HomepageBloc extends Bloc with LoggerMixin { } Future _onHomepageRefreshRequested(HomepageRefreshRequested event, Emitter emit) async { + _pageGeneration++; // Clear data. emit(const HomepageState(status: HomepageStatus.loading)); @@ -244,8 +252,56 @@ class HomepageBloc extends Bloc with LoggerMixin { } } + /// Ask the forum again for today's daily red packet only (its entry on the greeting card had none). + /// + /// The homepage document is fetched like a refresh does (same identity checks and daily rewards handling in + /// [ForumHomeRepository]), but only [HomepageState.dailyRedPacket] and [HomepageState.formHash] change: the loaded + /// page stays on screen, nothing else is reloaded. The answer is dropped when the page was reloaded or the account + /// changed meanwhile, or when the document belongs to another account. + Future _onDailyRedPacketCheckRequested( + HomepageDailyRedPacketCheckRequested event, + Emitter emit, + ) async { + final uid = _authenticationRepository.currentUser?.uid; + if (uid == null || state.status != HomepageStatus.success || state.checkingDailyRedPacket) { + return; + } + final generation = _pageGeneration; + bool stillCurrent() => + generation == _pageGeneration && + state.status == HomepageStatus.success && + _authenticationRepository.currentUser?.uid == uid; + + emit(state.copyWith(checkingDailyRedPacket: true)); + final result = await _forumHomeRepository.fetchHomePage(force: true).run(); + if (!stillCurrent()) { + debug('drop the daily red packet check for uid $uid: the page or the account changed meanwhile'); + if (state.checkingDailyRedPacket) { + emit(state.copyWith(checkingDailyRedPacket: false)); + } + return; + } + switch (result) { + case Left(:final value): + handle(value); + emit(state.copyWith(checkingDailyRedPacket: false)); + case Right(:final value) when parseLoggedUidFromDocument(value) != uid: + debug('drop the daily red packet check for uid $uid: the page belongs to another account'); + emit(state.copyWith(checkingDailyRedPacket: false)); + case Right(:final value): + emit( + state.copyWith( + checkingDailyRedPacket: false, + dailyRedPacket: parseDailyRedPacketConfig(value), + formHash: parseFormHash(value), + ), + ); + } + } + Future _onHomepageAuthChanged(HomepageAuthChanged event, Emitter emit) async { if (event.prev != event.curr) { + _pageGeneration++; // The cached `forum.php` was served to the previous user (or guest); nobody may show it again, and the // forced refresh below can fail (rate limit, offline), so drop it now rather than after the refresh. _forumHomeRepository.invalidate(); diff --git a/lib/features/homepage/bloc/homepage_event.dart b/lib/features/homepage/bloc/homepage_event.dart index c4f0c94a4..3b4e9c624 100644 --- a/lib/features/homepage/bloc/homepage_event.dart +++ b/lib/features/homepage/bloc/homepage_event.dart @@ -24,6 +24,17 @@ final class HomepageRefreshRequested extends HomepageEvent with HomepageRefreshR UserLoginInfo? userLoginInfo; } +/// User asks whether today's daily red packet is offered now, from its entry on the loaded homepage. +/// +/// Only the packet and the form hash are updated; the loaded page stays on screen, no loading state, and a refresh or +/// an account change meanwhile wins over the answer. +@MappableClass() +final class HomepageDailyRedPacketCheckRequested extends HomepageEvent + with HomepageDailyRedPacketCheckRequestedMappable { + /// Constructor. + const HomepageDailyRedPacketCheckRequested(); +} + /// User requests to login. @MappableClass() final class HomepageLoginRequested extends HomepageEvent with HomepageLoginRequestedMappable {} diff --git a/lib/features/homepage/bloc/homepage_state.dart b/lib/features/homepage/bloc/homepage_state.dart index dacf8d406..75bb7b58a 100644 --- a/lib/features/homepage/bloc/homepage_state.dart +++ b/lib/features/homepage/bloc/homepage_state.dart @@ -17,7 +17,8 @@ enum HomepageStatus { success, /// Failed to load data. - failure; + failure + ; /// Is [initial]? bool get isInitial => this == HomepageStatus.initial; @@ -50,6 +51,7 @@ final class HomepageState with HomepageStateMappable { this.hasUnreadMessage = false, this.dailyRedPacket, this.formHash, + this.checkingDailyRedPacket = false, }); /// Loading status. @@ -87,4 +89,8 @@ final class HomepageState with HomepageStateMappable { /// Form hash of the current session found in the homepage, required to claim the daily red packet. final String? formHash; + + /// A check of the daily red packet alone ([HomepageDailyRedPacketCheckRequested]) is running; the rest of the page + /// stays as it is. + final bool checkingDailyRedPacket; } diff --git a/lib/features/homepage/view/homepage_page.dart b/lib/features/homepage/view/homepage_page.dart index 6a5615d60..2be8aaef7 100644 --- a/lib/features/homepage/view/homepage_page.dart +++ b/lib/features/homepage/view/homepage_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; @@ -9,29 +10,72 @@ import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/blocking/utils/block_filter.dart'; +import 'package:tsdm_client/features/checkin/bloc/checkin_bloc.dart'; import 'package:tsdm_client/features/checkin/widgets/checkin_button.dart'; import 'package:tsdm_client/features/home/cubit/home_cubit.dart'; import 'package:tsdm_client/features/homepage/bloc/homepage_bloc.dart'; +import 'package:tsdm_client/features/homepage/models/models.dart'; import 'package:tsdm_client/features/homepage/widgets/guide_section.dart'; +import 'package:tsdm_client/features/homepage/widgets/home_dashboard.dart'; import 'package:tsdm_client/features/homepage/widgets/user_operation_dialog.dart'; import 'package:tsdm_client/features/homepage/widgets/widgets.dart'; import 'package:tsdm_client/features/need_login/view/need_login_page.dart'; import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; import 'package:tsdm_client/features/notification/repository/notification_info_repository.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; import 'package:tsdm_client/features/profile/repository/profile_repository.dart'; -import 'package:tsdm_client/features/red_packet/widgets/daily_red_packet_button.dart'; import 'package:tsdm_client/features/root/stream/scroll_to_top_stream.dart'; -import 'package:tsdm_client/features/settings/widgets/support_development_dialog.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/card/forum_card.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/notice_button.dart'; const _showFabOffset = 100; +/// Widest the homepage content grows; wider windows center it. +const _maxContentWidth = 1320.0; + +/// Content width from which the side column appears. +const _wideLayoutWidth = 960.0; + +/// Content width from which statistics and support sit side by side. +const _mediumLayoutWidth = 600.0; + +/// Width of the side column in the wide layout. +const _railWidth = 320.0; + +/// Arrangement of the homepage cards. +enum HomeLayout { + /// One column, phone cards. + compact, + + /// One column, statistics and support side by side. + medium, + + /// Threads with a side column. + wide, +} + +/// Arrangement of the homepage in a content area [width] wide on [platform] (`Theme.of(context).platform`). +/// +/// Phones keep the compact layout at any width: a landscape phone (feedback 111) is not a wide window, its greeting +/// card would grow a huge title badge and stack the day's actions oddly. Like the forum lists +/// ([forumCardListIsDesktop]), only desktop platforms get the medium and wide layouts. +HomeLayout homeLayoutFor(double width, TargetPlatform platform) { + if (!forumCardListIsDesktop(platform)) { + return HomeLayout.compact; + } + return width >= _wideLayoutWidth + ? HomeLayout.wide + : width >= _mediumLayoutWidth + ? HomeLayout.medium + : HomeLayout.compact; +} + /// Homepage page. /// /// Be stateful because scrollable. @@ -83,6 +127,114 @@ class _HomepagePageState extends State { ); } + /// Pull to refresh: the homepage, and the title badge of the current account with it. + void _refresh(BuildContext context) { + context.read().add(HomepageRefreshRequested()); + final currentTitle = context.readOrNull(); + if (currentTitle != null) { + unawaited(currentTitle.ensureLoaded(force: true)); + } + } + + /// The loaded homepage, arranged for the available [width]. + /// + /// * Wide: threads on the left, statistics, tools and the support entry in a side column. + /// * Medium: one column, statistics and support side by side above the threads. + /// * Compact: one column; the greeting carries the day's actions and the today count, the threads come right after + /// it and the utility cards follow them, so no tall card pushes the threads down. + Widget _buildDashboard(BuildContext context, HomepageState state, double width) { + final layout = homeLayoutFor(width, Theme.of(context).platform); + final hasStatus = state.forumStatus != const ForumStatus.empty(); + final greeting = HomeGreetingCard( + username: state.loggedUserInfo?.username ?? '', + uid: context.read().currentUser?.uid, + forumStatus: state.forumStatus, + dailyRedPacket: state.dailyRedPacket, + formHash: state.formHash, + compact: layout == HomeLayout.compact, + // The red packet entry checks the packet alone: tapping it used to reload the whole homepage (feedback 110). + // Pull to refresh stays the way to reload everything. + onCheckDailyRedPacket: () => context.read().add(const HomepageDailyRedPacketCheckRequested()), + checkingDailyRedPacket: state.checkingDailyRedPacket, + ); + // The swiper block of the forum homepage is gone since Discuz! X5; kept in case it comes back. + final swiper = state.swiperUrlList.isEmpty + ? null + : WelcomeSection( + forumStatus: state.forumStatus, + loggedUserInfo: state.loggedUserInfo, + swiperUrlList: state.swiperUrlList, + ); + final pinned = state.pinnedThreadGroupList.isEmpty ? null : PinSection(state.pinnedThreadGroupList); + // The guide owns the fetch of the guide index page. It keeps its place in the tree (first child of the main + // column's Expanded, found by key among its siblings) in every layout, so a window resize or a rotation moves the + // loaded guide instead of fetching it again. + const guide = GuideSection(key: ValueKey('homepage-guide')); + const support = HomeSupportCard(); + + List spaced(List children, double gap) => [ + for (final (i, child) in children.whereType().indexed) ...[if (i > 0) SizedBox(height: gap), child], + ]; + + final List main; + List? rail; + switch (layout) { + case HomeLayout.wide: + main = [greeting, swiper, pinned, guide]; + rail = [ + if (hasStatus) HomeForumStatsCard(state.forumStatus), + const HomeToolsCard(columns: 2), + support, + ]; + case HomeLayout.medium: + main = [ + greeting, + if (hasStatus) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: HomeForumStatsCard(state.forumStatus)), + const SizedBox(width: 16), + const Expanded(child: support), + ], + ) + else + support, + swiper, + pinned, + guide, + const HomeToolsCard(columns: 4), + ]; + case HomeLayout.compact: + main = [ + greeting, + swiper, + pinned, + guide, + if (hasStatus) HomeForumStatsCard(state.forumStatus), + const HomeToolsCard(columns: 2), + support, + ]; + } + final gap = layout == HomeLayout.compact ? 12.0 : 16.0; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: spaced(main, gap)), + ), + if (rail != null) ...[ + const SizedBox(width: 24), + SizedBox( + width: _railWidth, + child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: spaced(rail, gap)), + ), + ], + ], + ); + } + @override void initState() { super.initState(); @@ -142,6 +294,8 @@ class _HomepagePageState extends State { hasPersonalMessage: allowHint ? state.hasUnreadMessage : null, ); context.read().add(NotificationUpdateAllRequested()); + // The greeting card shows whether this account checked in today, from the record of this app. + context.read().add(const CheckinStatusRequested()); }, ), ], @@ -170,40 +324,25 @@ class _HomepagePageState extends State { scrollController: _scrollController, controller: _refreshController, header: const MaterialHeader(), - onRefresh: () => context.read().add(HomepageRefreshRequested()), - childBuilder: (context, physics) => ListView( - physics: physics, - controller: _scrollController, - padding: edgeInsetsL12T12R12.add(context.safePadding()), - children: [ - WelcomeSection( - forumStatus: state.forumStatus, - loggedUserInfo: state.loggedUserInfo, - swiperUrlList: state.swiperUrlList, - ), - sizedBoxW12H12, - Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - color: Theme.of(context).colorScheme.tertiaryContainer, - child: ListTile( - leading: const Icon(Icons.favorite_border), - title: Text(context.t.aboutPage.supportDevelopment), - subtitle: Text(context.t.aboutPage.supportDevelopmentSubtitle), - trailing: const Icon(Icons.chevron_right), - textColor: Theme.of(context).colorScheme.onTertiaryContainer, - iconColor: Theme.of(context).colorScheme.onTertiaryContainer, - onTap: () async => showDialog( - context: context, - builder: (_) => const SupportDevelopmentDialog(), + onRefresh: () => _refresh(context), + childBuilder: (context, physics) => LayoutBuilder( + builder: (context, constraints) => ListView( + physics: physics, + controller: _scrollController, + padding: edgeInsetsL12T12R12.add(context.safePadding()), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: _buildDashboard( + context, + state, + math.min(constraints.maxWidth - 24, _maxContentWidth), + ), ), ), - ), - sizedBoxW12H12, - PinSection(state.pinnedThreadGroupList), - sizedBoxW12H12, - const GuideSection(), - ], + ], + ), ), ), }; @@ -212,16 +351,30 @@ class _HomepagePageState extends State { final username = state.loggedUserInfo?.username; final avatarUrl = state.loggedUserInfo?.avatarUrl; + // Check-in, the daily red packet and activities moved into the greeting card of the loaded page. Only a + // failed load, which has no greeting card, keeps them reachable here. While the page loads or refreshes + // the bar stays as it is around it (search, and the account actions once known) instead of flashing the + // old activity entry for the length of the refresh (feedback 110). + final showDailyActionsInBar = state.status == HomepageStatus.failure; + return Scaffold( appBar: AppBar( title: Text(context.t.homepage.title), actions: [ + if (showDailyActionsInBar) + IconButton( + icon: const Icon(Icons.event_outlined), + tooltip: context.t.activitiesPage.title, + onPressed: () async => context.pushNamed(ScreenPaths.activities), + ), IconButton( - icon: const Icon(Icons.event_outlined), - tooltip: context.t.activitiesPage.title, - onPressed: () async => context.pushNamed(ScreenPaths.activities), + icon: const Icon(Icons.search_outlined), + tooltip: context.t.searchPage.title, + onPressed: () async => context.pushNamed(ScreenPaths.search), ), if (username != null) ...[ + if (showDailyActionsInBar) const CheckinButton(enableSnackBar: true), + const NoticeButton(), IconButton( icon: SizedBox( width: 32, @@ -239,20 +392,8 @@ class _HomepagePageState extends State { ), ), ), - if (state.dailyRedPacket != null && state.formHash != null) - DailyRedPacketButton( - key: ValueKey('dailyRedPacket-${state.dailyRedPacket!.dateFlag}'), - config: state.dailyRedPacket!, - formHash: state.formHash!, - ), - const NoticeButton(), - const CheckinButton(enableSnackBar: true), ], - IconButton( - icon: const Icon(Icons.search_outlined), - tooltip: context.t.searchPage.title, - onPressed: () async => context.pushNamed(ScreenPaths.search), - ), + sizedBoxW4H4, ], ), body: SafeArea( diff --git a/lib/features/homepage/widgets/guide_section.dart b/lib/features/homepage/widgets/guide_section.dart index 2d85c90fc..ef3e6afe3 100644 --- a/lib/features/homepage/widgets/guide_section.dart +++ b/lib/features/homepage/widgets/guide_section.dart @@ -8,6 +8,7 @@ import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/features/homepage/cubit/guide_index_cubit.dart'; import 'package:tsdm_client/features/homepage/models/models.dart'; import 'package:tsdm_client/features/homepage/repository/guide_index_repository.dart'; +import 'package:tsdm_client/features/homepage/widgets/home_dashboard.dart'; import 'package:tsdm_client/features/replied_thread/cubit/replied_thread_cubit.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; @@ -19,10 +20,11 @@ Future _openGuideList(BuildContext context, String url, String title) asyn /// Homepage section showing the modules of the forum guide index page (GitHub #12). /// -/// `forum.php?mod=guide&view=index` lists 最新热门, 最新精华, 最新回复 and 最新发表 with a handful of threads each; -/// every module becomes a [GuideModuleCard] and a chip row on top links the full list pages, including 抢沙发 which -/// the page only offers in its nav row. The section owns its [GuideIndexCubit]: the homepage rebuilds this widget -/// after every refresh, so the page is fetched again together with the rest of the homepage. +/// `forum.php?mod=guide&view=index` lists 最新热门, 最新精华, 最新回复 and 最新发表 with a handful of threads each. +/// The modules are tabs of one card so the threads start right below the tab row instead of four stacked cards; the +/// 更多 button opens the full list page of the selected module and 抢沙发, which the page only offers in its nav row, +/// opens its list page. The section owns its [GuideIndexCubit]: the homepage rebuilds this widget after every refresh, +/// so the page is fetched again together with the rest of the homepage. class GuideSection extends StatelessWidget { /// Constructor. const GuideSection({this.maxCount = 10, this.repository, super.key}); @@ -42,74 +44,156 @@ class GuideSection extends StatelessWidget { return cubit; }, child: BlocBuilder( - builder: (context, state) { - final tr = context.t.homepage.guide; - final body = switch (state.status) { - GuideIndexStatus.initial || GuideIndexStatus.loading when state.modules.isEmpty => const Card( - margin: EdgeInsets.zero, - child: Padding(padding: edgeInsetsL12T12R12B12, child: CenteredCircularIndicator()), - ), - GuideIndexStatus.failure => Card( - margin: EdgeInsets.zero, - child: ListTile( - leading: const Icon(Icons.refresh_outlined), - title: Text(tr.failed), - onTap: () async => context.read().load(), - ), + builder: (context, state) => _GuideFeedCard(state, maxCount: maxCount), + ), + ); + } +} + +class _GuideFeedCard extends StatefulWidget { + const _GuideFeedCard(this.state, {required this.maxCount}); + + final GuideIndexState state; + final int maxCount; + + @override + State<_GuideFeedCard> createState() => _GuideFeedCardState(); +} + +class _GuideFeedCardState extends State<_GuideFeedCard> { + /// View key of the selected module, kept across reloads; the first module when unknown. + String? _selectedView; + + @override + Widget build(BuildContext context) { + final tr = context.t.homepage.guide; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final state = widget.state; + final modules = state.modules; + final matching = modules.where((e) => e.view == _selectedView); + final selected = matching.isNotEmpty ? matching.first : (modules.isNotEmpty ? modules.first : null); + + final Widget body; + if ((state.status == GuideIndexStatus.initial || state.status == GuideIndexStatus.loading) && modules.isEmpty) { + body = const Padding(padding: edgeInsetsL12T12R12B12, child: CenteredCircularIndicator()); + } else if (state.status == GuideIndexStatus.failure) { + body = ListTile( + leading: const Icon(Icons.refresh_outlined), + title: Text(tr.failed), + onTap: () async => context.read().load(), + ); + } else if (selected == null) { + body = Padding( + padding: edgeInsetsL16T16R16B16, + child: Text(tr.empty, style: textTheme.bodyMedium?.copyWith(color: colorScheme.outline)), + ); + } else { + body = GuideModuleList(selected, maxCount: widget.maxCount); + } + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: homeCardShape(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 4, 0), + child: Row( + children: [ + Expanded( + child: Text( + context.t.homepage.guideTitle, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + if (selected != null) + TextButton.icon( + onPressed: () async => _openGuideList(context, selected.moreUrl, selected.title), + icon: const Icon(Icons.chevron_right_outlined), + iconAlignment: IconAlignment.end, + label: Text(tr.more), + ), + ], ), - _ => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: edgeInsetsL8R8, + child: Row( children: [ - for (final (i, module) in state.modules.indexed) ...[ - if (i > 0) sizedBoxW12H12, - GuideModuleCard(module, maxCount: maxCount), - ], + for (final module in modules) + _GuideTab( + label: module.title, + selected: module == selected, + onTap: () => setState(() => _selectedView = module.view), + ), + sizedBoxW8H8, + ActionChip( + key: const ValueKey('guide-sofa'), + avatar: const Icon(Icons.weekend_outlined), + label: Text(tr.sofa), + onPressed: () async => _openGuideList(context, guideUrl('sofa'), tr.sofa), + ), + sizedBoxW8H8, ], ), - }; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [_GuideNavRow(state.modules), sizedBoxW12H12, body], - ); - }, + ), + Divider(height: 1, color: colorScheme.outlineVariant), + body, + ], ), ); } } -/// Quick links to the full list pages: one chip per module plus 抢沙发, like the nav row of the guide page. -class _GuideNavRow extends StatelessWidget { - const _GuideNavRow(this.modules); +/// A module tab with an underline when selected. +class _GuideTab extends StatelessWidget { + const _GuideTab({required this.label, required this.selected, required this.onTap}); - final List modules; + final String label; + final bool selected; + final VoidCallback onTap; @override Widget build(BuildContext context) { - final tr = context.t.homepage.guide; - return Wrap( - spacing: 8, - runSpacing: 4, - children: [ - for (final module in modules) - ActionChip( - label: Text(module.title), - onPressed: () async => _openGuideList(context, module.moreUrl, module.title), + final colorScheme = Theme.of(context).colorScheme; + return Semantics( + selected: selected, + button: true, + child: InkWell( + onTap: onTap, + borderRadius: const BorderRadius.vertical(top: Radius.circular(10)), + child: Container( + constraints: const BoxConstraints(minHeight: 46), + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: selected ? colorScheme.primary : Colors.transparent, width: 3), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: selected ? colorScheme.primary : colorScheme.onSurfaceVariant, + fontWeight: selected ? FontWeight.bold : FontWeight.w600, + ), ), - ActionChip( - key: const ValueKey('guide-sofa'), - avatar: const Icon(Icons.weekend_outlined), - label: Text(tr.sofa), - onPressed: () async => _openGuideList(context, guideUrl('sofa'), tr.sofa), ), - ], + ), ); } } -/// Card of one guide module: title, 更多 button and up to [maxCount] threads. -class GuideModuleCard extends StatelessWidget { +/// Threads of one guide module, up to [maxCount], or the page's own empty message. +class GuideModuleList extends StatelessWidget { /// Constructor. - const GuideModuleCard(this.module, {this.maxCount = 10, super.key}); + const GuideModuleList(this.module, {this.maxCount = 10, super.key}); /// The module to show. final GuideModule module; @@ -120,47 +204,27 @@ class GuideModuleCard extends StatelessWidget { @override Widget build(BuildContext context) { final tr = context.t.homepage.guide; - final textTheme = Theme.of(context).textTheme; final colorScheme = Theme.of(context).colorScheme; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Padding( - padding: edgeInsetsT8, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - sizedBoxW12H12, - Expanded( - child: Text(module.title, style: textTheme.titleLarge, overflow: TextOverflow.ellipsis), - ), - TextButton.icon( - onPressed: () async => _openGuideList(context, module.moreUrl, module.title), - icon: const Icon(Icons.chevron_right_outlined), - iconAlignment: IconAlignment.end, - label: Text(tr.more), - ), - sizedBoxW4H4, - ], - ), - if (module.items.isEmpty) - Padding( - padding: edgeInsetsL12R12B12, - child: Text( - module.emptyMessage ?? tr.empty, - style: textTheme.bodyMedium?.copyWith(color: colorScheme.outline), - ), - ) - else ...[ - ...module.items.take(maxCount).map(_GuideItemTile.new), - sizedBoxW8H8, - ], - ], + if (module.items.isEmpty) { + return Padding( + padding: edgeInsetsL16T16R16B16, + child: Text( + module.emptyMessage ?? tr.empty, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.outline), ), - ), + ); + } + final items = module.items.take(maxCount).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (i, item) in items.indexed) ...[ + if (i > 0) Divider(height: 1, indent: 16, endIndent: 16, color: colorScheme.outlineVariant), + _GuideItemTile(item), + ], + sizedBoxW4H4, + ], ); } } @@ -184,7 +248,7 @@ class _GuideItemTile extends StatelessWidget { queryParameters: {'tid': item.tid, 'appBarTitle': item.title}, ), child: Padding( - padding: edgeInsetsL12T4R12B4, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -194,7 +258,8 @@ class _GuideItemTile extends StatelessWidget { overflow: TextOverflow.ellipsis, style: textTheme.bodyLarge?.copyWith( color: item.highlighted ? colorScheme.error : null, - fontWeight: item.highlighted ? FontWeight.bold : null, + fontWeight: item.highlighted ? FontWeight.bold : FontWeight.w500, + height: 1.45, ), ), sizedBoxW4H4, diff --git a/lib/features/homepage/widgets/home_dashboard.dart b/lib/features/homepage/widgets/home_dashboard.dart new file mode 100644 index 000000000..fa8ecc716 --- /dev/null +++ b/lib/features/homepage/widgets/home_dashboard.dart @@ -0,0 +1,579 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/features/checkin/widgets/checkin_button.dart'; +import 'package:tsdm_client/features/homepage/models/models.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/features/red_packet/models/models.dart'; +import 'package:tsdm_client/features/red_packet/widgets/daily_red_packet_button.dart'; +import 'package:tsdm_client/features/settings/widgets/support_development_dialog.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// Corner radius of the homepage cards, the app wide [appSurfaceRadius]. +const double homeCardRadius = appSurfaceRadius; + +/// Shape shared by the homepage cards, the app wide [appSurfaceShape]. +ShapeBorder homeCardShape(BuildContext context) => appSurfaceShape(context); + +/// Least width left to the greeting beside the title badge; below it the badge goes to its own line. +const _minGreetingWidth = 168.0; + +/// Gap between the greeting and the title badge. +const _greetingBadgeGap = 12.0; + +/// Least width (at text scale 1) of the phone greeting card content showing check-in and red packet on one row. +const _dailyActionsRowWidth = 300.0; + +/// Least width (at text scale 1) of the phone greeting card content showing all four actions of the day on one row +/// (check-in, red packet, activities, medals and titles): a phone in landscape (feedback 113). +const _allActionsRowWidth = 560.0; + +/// Width of the title badge in the greeting card on wide layouts, larger than the natural 184px of the image (user +/// request of 2026-09-27, replacing the former 184px target). +const homeGreetingBadgeWideWidth = 240.0; + +/// Least width of the title badge in the greeting card on phones, unless the card is narrower. +const homeGreetingBadgeCompactMinWidth = 160.0; + +/// Largest width of the title badge in the greeting card on phones. +const homeGreetingBadgeCompactMaxWidth = 184.0; + +/// Width of the title badge in the greeting card whose content is [available] wide. +/// +/// Wide layouts show the image 240px wide; phones give it 160 to 184px depending on the room. Only in this card the +/// image is enlarged past its natural 184px ([SecondaryTitleBadge.fitWidth] is not used here, it caps at the natural +/// width); it stays contained in its 184:100 box, never cropped nor stretched, and never wider than the card. When the +/// greeting would be left too little room beside it, the badge goes below the greeting ([homeGreetingBadgeBeside]). +double homeGreetingBadgeWidth(double available, {required bool compact}) { + final preferred = compact + ? (available * 0.45).clamp(homeGreetingBadgeCompactMinWidth, homeGreetingBadgeCompactMaxWidth) + : homeGreetingBadgeWideWidth; + return math.max(0, math.min(available, preferred)); +} + +/// Whether the greeting leaves enough room beside a badge of [badgeWidth] in [available] width. +bool homeGreetingBadgeBeside(double available, double badgeWidth) => + available - badgeWidth - _greetingBadgeGap >= _minGreetingWidth; + +/// Least width of the title badge beside the greeting on phones; with less room it goes below the greeting instead of +/// shrinking further. +const homeGreetingBadgeCompactBesideMinWidth = 144.0; + +/// Least width (at text scale 1) the phone greeting keeps beside the title badge; its date line may wrap there. +const _minCompactGreetingWidth = 148.0; + +/// Width of the title badge in the greeting header whose content is [available] wide, and whether it sits beside the +/// greeting. +/// +/// Wide layouts: [homeGreetingBadgeWidth] and [homeGreetingBadgeBeside], unchanged. Phones: the greeting keeps +/// [_minCompactGreetingWidth] scaled by [textScale]; the badge takes its preferred 160–184px beside it, or the room +/// left when that is still at least [homeGreetingBadgeCompactBesideMinWidth] (a 384px phone used to put it below the +/// greeting, leaving the right half of the card empty, feedback 110). Only a narrower room or large text moves it +/// below, at its preferred width. +({double width, bool beside}) homeGreetingBadgeLayout( + double available, { + required bool compact, + double textScale = 1, +}) { + final preferred = homeGreetingBadgeWidth(available, compact: compact); + if (!compact) { + return (width: preferred, beside: homeGreetingBadgeBeside(available, preferred)); + } + final room = available - _greetingBadgeGap - _minCompactGreetingWidth * textScale; + if (room >= preferred) { + return (width: preferred, beside: true); + } + if (room >= homeGreetingBadgeCompactBesideMinWidth) { + return (width: room, beside: true); + } + return (width: preferred, beside: false); +} + +/// Greeting text for the local [hour]. +String homeGreeting(BuildContext context, int hour, String name) { + final tr = context.t.homepage.greeting; + return switch (hour) { + >= 5 && < 11 => tr.morning(name: name), + >= 11 && < 18 => tr.afternoon(name: name), + >= 18 && < 23 => tr.evening(name: name), + _ => tr.night(name: name), + }; +} + +/// First card of the homepage: greeting, the current account's secondary title and today's things to do. +/// +/// Check-in, the daily red packet and activities used to crowd the app bar; they are the actions of the day here. +/// Only data the forum provides is shown: the today count of the forum status bar, no streaks or invented numbers. +class HomeGreetingCard extends StatelessWidget { + /// Constructor. + const HomeGreetingCard({ + required this.username, + required this.uid, + required this.forumStatus, + required this.dailyRedPacket, + required this.formHash, + required this.compact, + this.onCheckDailyRedPacket, + this.checkingDailyRedPacket = false, + this.claimDailyRedPacket, + super.key, + }); + + /// Name of the logged in account. + final String username; + + /// Uid of the logged in account, for its title badge. + final int? uid; + + /// Forum statistics from the homepage, [ForumStatus.empty] when the page had none. + final ForumStatus forumStatus; + + /// Today's red packet, null when there is none or it was claimed. + final DailyRedPacketConfig? dailyRedPacket; + + /// Form hash required to claim [dailyRedPacket]. + final String? formHash; + + /// Narrow layout: actions stacked under the greeting. + final bool compact; + + /// Check the daily red packet again, offered by its entry when the page had no packet; only the packet is updated. + final VoidCallback? onCheckDailyRedPacket; + + /// The check of [onCheckDailyRedPacket] is running. + final bool checkingDailyRedPacket; + + /// Claim request of the daily red packet, the plugin's endpoint when null (tests pass a fake). + final DailyRedPacketClaim? claimDailyRedPacket; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final now = DateTime.now(); + final hasStatus = forumStatus != const ForumStatus.empty(); + + final greeting = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.t.appName} · ${MaterialLocalizations.of(context).formatMediumDate(now)}', + // Phones keep the greeting narrower beside the title badge: the date wraps instead of being cut. + maxLines: compact ? 2 : 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelMedium?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600), + ), + sizedBoxW4H4, + Text( + homeGreeting(context, now.hour, username), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: (compact ? textTheme.titleLarge : textTheme.headlineSmall)?.copyWith(fontWeight: FontWeight.bold), + ), + if (hasStatus) ...[ + sizedBoxW4H4, + Text( + context.t.homepage.todayPosts(count: forumStatus.todayCount), + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ], + ); + + // Always visible next to the check-in, with the state the homepage can tell (claimable, claimed in this run, none + // to claim now, needs a login). + final redPacket = DailyRedPacketEntry( + uid: uid, + config: dailyRedPacket, + formHash: formHash, + onCheck: onCheckDailyRedPacket, + checking: checkingDailyRedPacket, + claim: claimDailyRedPacket, + ); + final activities = _QuickAction( + icon: Icons.event_outlined, + label: context.t.activitiesPage.title, + onPressed: () async => context.pushNamed(ScreenPaths.activities), + ); + final medals = _QuickAction( + icon: Icons.workspace_premium_outlined, + label: context.t.medalTitleHub.title, + onPressed: () async => context.pushNamed(ScreenPaths.medalTitleHub), + ); + final quickActions = Wrap( + spacing: 8, + runSpacing: 8, + children: [if (!compact) redPacket, activities, medals], + ); + + final checkin = CheckinButton(enableSnackBar: true, label: context.t.homepage.welcome.checkin); + + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: homeCardShape(context), + color: colorScheme.surfaceContainerLow, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment.topRight, + radius: 1.4, + colors: [colorScheme.primaryContainer.withValues(alpha: 0.55), colorScheme.surfaceContainerLow], + stops: const [0, 0.6], + ), + ), + child: Padding( + padding: compact ? edgeInsetsL16T16R16B16 : edgeInsetsL24T24R24B24, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HomeGreetingHeader(greeting: greeting, uid: uid, compact: compact), + SizedBox(height: compact ? 14 : 20), + if (compact) + // All four actions on one row when they fit (a phone in landscape); check-in and red packet side by + // side with the two entries below them on a portrait phone; everything stacked when even those two do + // not fit (narrow window, large text). + LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(14) / 14; + if (constraints.maxWidth >= _allActionsRowWidth * textScale) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: checkin), + sizedBoxW8H8, + Expanded(child: redPacket), + sizedBoxW8H8, + activities, + sizedBoxW8H8, + medals, + ], + ); + } + if (constraints.maxWidth >= _dailyActionsRowWidth * textScale) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: checkin), + sizedBoxW8H8, + Expanded(child: redPacket), + ], + ), + sizedBoxW12H12, + quickActions, + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [checkin, sizedBoxW8H8, redPacket, sizedBoxW12H12, quickActions], + ); + }, + ) + else + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox(constraints: const BoxConstraints(minWidth: 180), child: checkin), + sizedBoxW12H12, + Expanded(child: quickActions), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Top of the greeting card: the [greeting] and the title badge of the account [uid], beside the greeting when both +/// fit and below it otherwise ([homeGreetingBadgeLayout]). +class HomeGreetingHeader extends StatelessWidget { + /// Constructor. + const HomeGreetingHeader({required this.greeting, required this.uid, required this.compact, super.key}); + + /// Date line, greeting and today count. + final Widget greeting; + + /// Logged in account, for its title badge. + final int? uid; + + /// Phone layout. + final bool compact; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final (width: badgeWidth, :beside) = homeGreetingBadgeLayout( + constraints.maxWidth, + compact: compact, + textScale: MediaQuery.textScalerOf(context).scale(14) / 14, + ); + if (beside) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: greeting), + CurrentAccountTitleBadge( + uid: uid, + width: badgeWidth, + padding: const EdgeInsetsDirectional.only(start: _greetingBadgeGap), + ), + ], + ); + } + // Too narrow for both side by side: the badge wraps below the greeting, the text keeps its width. + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + greeting, + CurrentAccountTitleBadge( + uid: uid, + width: badgeWidth, + padding: const EdgeInsets.only(top: _greetingBadgeGap), + ), + ], + ); + }, + ); +} + +class _QuickAction extends StatelessWidget { + const _QuickAction({required this.icon, required this.label, required this.onPressed}); + + final IconData icon; + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) => OutlinedButton.icon( + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), + icon: Icon(icon), + label: Text(label), + onPressed: onPressed, + ); +} + +/// The three counters of the forum status bar: today, yesterday and all posts. +class HomeForumStatsCard extends StatelessWidget { + /// Constructor. + const HomeForumStatsCard(this.forumStatus, {super.key}); + + /// Forum statistics. + final ForumStatus forumStatus; + + @override + Widget build(BuildContext context) { + final tr = context.t.homepage.forumStatus; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + Widget cell(String label, String value) => Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + maxLines: 1, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + final divider = SizedBox(height: 40, child: VerticalDivider(width: 20, color: colorScheme.outlineVariant)); + + return Card( + margin: EdgeInsets.zero, + shape: homeCardShape(context), + child: Padding( + padding: edgeInsetsL16T12R16B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.t.homepage.forumActivity, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + sizedBoxW8H8, + Row( + children: [ + cell(tr.today, forumStatus.todayCount), + divider, + cell(tr.yesterday, forumStatus.yesterdayCount), + divider, + cell(tr.threads, forumStatus.threadCount), + ], + ), + ], + ), + ), + ); + } +} + +/// Entry of the voluntary support dialog: visible, but not in the way of the threads. +class HomeSupportCard extends StatelessWidget { + /// Constructor. + const HomeSupportCard({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + shape: homeCardShape(context), + child: InkWell( + onTap: () async => showDialog(context: context, builder: (_) => const SupportDevelopmentDialog()), + child: Padding( + padding: edgeInsetsL16T12R16B12, + child: Row( + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: SizedBox( + width: 40, + height: 40, + child: Icon(Icons.favorite_border, color: colorScheme.onTertiaryContainer), + ), + ), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.aboutPage.supportDevelopment, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + sizedBoxW2H2, + Text( + context.t.aboutPage.supportDevelopmentSubtitle, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + sizedBoxW2H2, + Text( + context.t.homepage.supportNote, + style: textTheme.labelSmall?.copyWith(color: colorScheme.tertiary), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), + ], + ), + ), + ), + ); + } +} + +/// Fixed entries of pages that are not top level destinations, the medal & title hub first. +class HomeToolsCard extends StatelessWidget { + /// Constructor. + const HomeToolsCard({required this.columns, super.key}); + + /// Number of entries per row. + final int columns; + + @override + Widget build(BuildContext context) { + final tr = context.t.homepage; + final textTheme = Theme.of(context).textTheme; + final entries = <(IconData, String, String)>[ + (Icons.workspace_premium_outlined, context.t.medalTitleHub.title, ScreenPaths.medalTitleHub), + (Icons.article_outlined, tr.welcome.myThread, ScreenPaths.myThread), + (Icons.star_outline, tr.welcome.favorite, ScreenPaths.favorite), + (Icons.history_outlined, tr.welcome.history, ScreenPaths.threadVisitHistory), + (Icons.account_balance_outlined, context.t.bank.title, ScreenPaths.bank), + ]; + return Card( + margin: EdgeInsets.zero, + shape: homeCardShape(context), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(tr.tools, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + sizedBoxW12H12, + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final width = (constraints.maxWidth - spacing * (columns - 1)) / columns; + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final (icon, label, path) in entries) + SizedBox( + width: width, + child: _ToolButton(icon: icon, label: label, onTap: () async => context.pushNamed(path)), + ), + ], + ); + }, + ), + ], + ), + ), + ); + } +} + +class _ToolButton extends StatelessWidget { + const _ToolButton({required this.icon, required this.label, required this.onTap}); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + sizedBoxW8H8, + Expanded( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/homepage/widgets/user_operation_dialog.dart b/lib/features/homepage/widgets/user_operation_dialog.dart index 888a7d12d..2445d4ba5 100644 --- a/lib/features/homepage/widgets/user_operation_dialog.dart +++ b/lib/features/homepage/widgets/user_operation_dialog.dart @@ -5,6 +5,7 @@ import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -36,6 +37,18 @@ class UserOperationDialog extends StatelessWidget with LoggerMixin { /// The latest thread url is a dynamic url generated by server. final String? latestThreadUrl; + /// A row of the menu: icon, title, rounded ink; closes the menu and opens [screen]. + Widget _entry(BuildContext context, IconData icon, String title, String screen) => ListTile( + leading: Icon(icon), + title: Text(title), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + context.pop(); + await context.pushNamed(screen); + }, + ); + @override Widget build(BuildContext context) { // Expected containing two pairs of nodes: @@ -44,56 +57,43 @@ class UserOperationDialog extends StatelessWidget with LoggerMixin { // 2. Latest thread. final tr = context.t.homepage.welcome; + final divider = Divider( + height: 9, + indent: 16, + endIndent: 16, + color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.6), + ); return CustomAlertDialog.sync( title: Row( children: [ HeroUserAvatar(username: username, avatarUrl: avatarUrl, heroTag: username, minRadius: 30), sizedBoxW12H12, - Expanded(child: SingleLineText(username, style: Theme.of(context).textTheme.titleLarge)), + Expanded( + child: SingleLineText( + username, + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), ], ), + // Same entries and order as before, grouped: the account itself, then threads, then the account list. content: Column( mainAxisSize: MainAxisSize.min, children: [ - ListTile( - title: Text(context.t.profilePage.title), - onTap: () async { - context.pop(); - await context.pushNamed(ScreenPaths.profile); - }, - ), - ListTile( - title: Text(tr.history), - onTap: () async { - context.pop(); - await context.pushNamed(ScreenPaths.threadVisitHistory); - }, - ), - ListTile( - title: Text(tr.myThread), - onTap: () async { - context.pop(); - await context.pushNamed(ScreenPaths.myThread); - }, - ), - ListTile( - title: Text(tr.favorite), - onTap: () async { - context.pop(); - await context.pushNamed(ScreenPaths.favorite); - }, - ), + _entry(context, Icons.person_outline, context.t.profilePage.title, ScreenPaths.profile), + _entry(context, Icons.military_tech_outlined, context.t.medalTitleHub.title, ScreenPaths.medalTitleHub), + divider, + _entry(context, Icons.history_outlined, tr.history, ScreenPaths.threadVisitHistory), + _entry(context, Icons.article_outlined, tr.myThread, ScreenPaths.myThread), + _entry(context, Icons.star_outline, tr.favorite, ScreenPaths.favorite), // Switching or adding an account was only reachable through the settings page (#16). + _entry(context, Icons.manage_accounts_outlined, context.t.manageAccountPage.title, ScreenPaths.manageAccount), ListTile( - title: Text(context.t.manageAccountPage.title), - onTap: () async { - context.pop(); - await context.pushNamed(ScreenPaths.manageAccount); - }, - ), - ListTile( + leading: const Icon(Icons.update_outlined), title: Text(tr.latestThread), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + trailing: latestThreadUrl == null ? null : const Icon(Icons.chevron_right), onTap: latestThreadUrl == null ? null : () async { diff --git a/lib/features/image/view/image_detail_page.dart b/lib/features/image/view/image_detail_page.dart index b6bf6dbad..78db0fc9c 100644 --- a/lib/features/image/view/image_detail_page.dart +++ b/lib/features/image/view/image_detail_page.dart @@ -1,15 +1,20 @@ import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/utils/show_bottom_sheet.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart'; import 'package:tsdm_client/widgets/copy_content_dialog.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page to show a detail image. -final class ImageDetailPage extends StatelessWidget { +/// +/// The image fills the page and keeps its pinch, double tap and drag zoom; a floating control bar at the bottom +/// resets the zoom, shows the cache statistics and opens the image actions (save, copy the url, reload, sticker). +final class ImageDetailPage extends StatefulWidget { /// Constructor. const ImageDetailPage(this.imageUrl, {super.key}); @@ -18,51 +23,114 @@ final class ImageDetailPage extends StatelessWidget { /// Usually this image is loaded from global cache. final String imageUrl; + @override + State createState() => _ImageDetailPageState(); +} + +class _ImageDetailPageState extends State { + final _scaleStateController = PhotoViewScaleStateController(); + + @override + void dispose() { + _scaleStateController.dispose(); + super.dispose(); + } + + Future _showStatistics(BuildContext context) async { + final tr = context.t.imageDetailPage; + await showCopyContentDialogFutureBuilder( + context: context, + // Calling `then` is better than nested callback. + // ignore: prefer_async_await + contentFuture: getIt.get().getEnsureCachedFullInfo(widget.imageUrl).then((imageInfo) { + if (imageInfo == null) { + return const []; + } + return [ + CopyableContent(name: tr.url, data: imageInfo.url), + CopyableContent(name: tr.cacheName, data: imageInfo.fileName), + CopyableContent(name: tr.cacheSize, data: imageInfo.cacheSize), + CopyableContent( + name: tr.sizeTitle, + data: tr.sizeValue(width: imageInfo.width, height: imageInfo.height), + ), + ]; + }), + errorBuilder: (_, err) => Center(child: Text(tr.failedToLoadWithReason(reason: '$err'))), + title: tr.statistics, + route: DialogPaths.imageDetail, + ); + } + + Widget _buildControlBar(BuildContext context) { + final tr = context.t.imageDetailPage; + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), + ), + child: Padding( + padding: edgeInsetsL4R4, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.fit_screen_outlined), + tooltip: tr.fitToScreen, + onPressed: () => _scaleStateController.scaleState = PhotoViewScaleState.initial, + ), + IconButton( + icon: const Icon(Icons.info_outline), + tooltip: tr.statistics, + onPressed: () async => _showStatistics(context), + ), + IconButton( + icon: const Icon(Icons.more_horiz), + tooltip: context.t.imageBottomSheet.title, + onPressed: () async => + showImageActionBottomSheet(context: context, imageUrl: widget.imageUrl, showCheckDetail: false), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { final tr = context.t.imageDetailPage; return Scaffold( - appBar: AppBar( - title: Text(tr.title), - actions: [ - IconButton( - icon: const Icon(Icons.info_outline), - tooltip: tr.statistics, - onPressed: () async => showCopyContentDialogFutureBuilder( - context: context, - // Calling `then` is better than nested callback. - // ignore: prefer_async_await - contentFuture: getIt.get().getEnsureCachedFullInfo(imageUrl).then((imageInfo) { - if (imageInfo == null) { - return const []; - } - return [ - CopyableContent(name: tr.url, data: imageInfo.url), - CopyableContent(name: tr.cacheName, data: imageInfo.fileName), - CopyableContent(name: tr.cacheSize, data: imageInfo.cacheSize), - CopyableContent( - name: tr.sizeTitle, - data: tr.sizeValue(width: imageInfo.width, height: imageInfo.height), - ), - ]; - }), - errorBuilder: (_, err) => Center(child: Text(tr.failedToLoadWithReason(reason: '$err'))), - title: tr.statistics, - route: DialogPaths.imageDetail, + appBar: AppBar(title: Text(tr.title)), + body: Stack( + children: [ + // Full size viewer, not constrained by any reading width: the image keeps the whole page. + Positioned.fill( + child: PhotoView( + imageProvider: CachedImageProvider(widget.imageUrl), + scaleStateController: _scaleStateController, + maxScale: 3.0, + minScale: 0.3, + initialScale: PhotoViewComputedScale.contained, + backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerLowest), + loadingBuilder: (_, _) => const CenteredCircularIndicator(), + ), + ), + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Center(child: _buildControlBar(context)), + ), ), ), ], ), - body: SafeArea( - child: PhotoView( - imageProvider: CachedImageProvider(imageUrl), - maxScale: 3.0, - minScale: 0.3, - initialScale: PhotoViewComputedScale.contained, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerLowest), - loadingBuilder: (_, _) => const CenteredCircularIndicator(), - ), - ), ); } } diff --git a/lib/features/jump_page/widgets/jump_page_dialog.dart b/lib/features/jump_page/widgets/jump_page_dialog.dart index cd78b857c..5f71bd625 100644 --- a/lib/features/jump_page/widgets/jump_page_dialog.dart +++ b/lib/features/jump_page/widgets/jump_page_dialog.dart @@ -46,12 +46,61 @@ class _JumpPageDialogState extends State { textController.dispose(); } + /// Select [page], kept within the pages of the thread. + void _select(int page) => setState(() { + currentPage = page.clamp(widget.min, widget.max); + textController.text = '$currentPage'; + }); + @override Widget build(BuildContext context) { + final localizations = MaterialLocalizations.of(context); + final atStart = currentPage <= math.max(widget.min, 1); + final atEnd = currentPage >= widget.max; return CustomAlertDialog.sync( title: Text(context.t.jumpDialog.title), content: Column( children: [ + // Quick steps: first, previous, next and last page, with the platform's own tooltips. + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.first_page), + tooltip: localizations.firstPageTooltip, + onPressed: atStart ? null : () => _select(math.max(widget.min, 1)), + ), + IconButton( + icon: const Icon(Icons.chevron_left), + tooltip: localizations.previousPageTooltip, + onPressed: atStart ? null : () => _select(currentPage - 1), + ), + Flexible( + child: Padding( + padding: edgeInsetsL8R8, + child: Text( + '$currentPage / ${widget.max}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + tooltip: localizations.nextPageTooltip, + onPressed: atEnd ? null : () => _select(currentPage + 1), + ), + IconButton( + icon: const Icon(Icons.last_page), + tooltip: localizations.lastPageTooltip, + onPressed: atEnd ? null : () => _select(widget.max), + ), + ], + ), Slider( autofocus: true, // Since flutter 3.29 diff --git a/lib/features/latest_thread/view/latest_thread_page.dart b/lib/features/latest_thread/view/latest_thread_page.dart index cac4598e2..02de5fdab 100644 --- a/lib/features/latest_thread/view/latest_thread_page.dart +++ b/lib/features/latest_thread/view/latest_thread_page.dart @@ -1,12 +1,12 @@ import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/latest_thread/bloc/latest_thread_bloc.dart'; import 'package:tsdm_client/features/latest_thread/repository/latest_thread_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/thread_card/thread_card.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -52,28 +52,16 @@ class _LatestThreadPageState extends State { }, child: state.threadList.isEmpty // The guide page can be empty (最新精华 has no threads); say so instead of showing a blank list. - ? ListView( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - children: [ - Padding( - padding: edgeInsetsL12T12R12B12, - child: Text( - context.t.latestThreadPage.empty, - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ), - ], - ) - : ListView.separated( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.threadList.length, - itemBuilder: (context, index) { - return LatestThreadCard(state.threadList[index]); - }, - separatorBuilder: (context, index) => sizedBoxW4H4, + ? AppStateView(icon: Icons.article_outlined, message: context.t.latestThreadPage.empty) + : AppCenteredList( + builder: (context, side, _) => ListView.separated( + padding: side.copyWith(top: 8).add(context.safePadding()), + itemCount: state.threadList.length, + itemBuilder: (context, index) { + return LatestThreadCard(state.threadList[index]); + }, + separatorBuilder: (context, index) => appListSeparator, + ), ), ); } diff --git a/lib/features/medal_center/view/medal_center_page.dart b/lib/features/medal_center/view/medal_center_page.dart index 6c92a9c37..89af47612 100644 --- a/lib/features/medal_center/view/medal_center_page.dart +++ b/lib/features/medal_center/view/medal_center_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fpdart/fpdart.dart' show Left, Right; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; @@ -14,6 +15,7 @@ import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -78,41 +80,60 @@ class _MedalCenterPageState extends State { super.dispose(); } - Widget _searchBar(MedalCenterState state, TranslationsMedalCenterEn tr) => Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), - child: TextField( - key: const ValueKey('medal-search'), - controller: _searchController, - enabled: state.searchForm != null, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: tr.searchHint, - prefixIcon: const Icon(Icons.search), - border: const OutlineInputBorder(), - isDense: true, - suffixIcon: state.query == null - ? null - : IconButton( - tooltip: tr.clearSearch, - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - unawaited(_cubit.clearSearch()); - }, - ), + Widget _searchBar(MedalCenterState state, TranslationsMedalCenterEn tr) => AppCenteredList( + builder: (context, padding, _) => Padding( + padding: padding.copyWith(top: 8, bottom: 4), + child: TextField( + key: const ValueKey('medal-search'), + controller: _searchController, + enabled: state.searchForm != null, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: tr.searchHint, + prefixIcon: const Icon(Icons.search), + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(appInnerRadius), + borderSide: BorderSide.none, + ), + isDense: true, + suffixIcon: state.query == null + ? null + : IconButton( + tooltip: tr.clearSearch, + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + unawaited(_cubit.clearSearch()); + }, + ), + ), + onSubmitted: (text) => unawaited(_cubit.search(text)), ), - onSubmitted: (text) => unawaited(_cubit.search(text)), ), ); - Widget _image(String? url) => SizedBox( - width: 64, - height: 56, - child: - widget.imageBuilder?.call(url) ?? - (url == null - ? const Icon(Icons.image_not_supported_outlined) - : CachedImage(url, width: 64, height: 56, fit: BoxFit.contain)), + /// Medal artwork in a fixed square, contained and never cropped. + Widget _image(String? url, {double size = 56}) => AppInsetBlock( + padding: const EdgeInsets.all(4), + child: SizedBox( + width: size, + height: size, + child: + widget.imageBuilder?.call(url) ?? + (url == null + ? Icon(Icons.image_not_supported_outlined, color: Theme.of(context).colorScheme.outline) + : CachedImage(url, width: size, height: size, fit: BoxFit.contain)), + ), + ); + + /// Dialog title with the icon tile of the app's dialogs. + Widget _dialogTitle(IconData icon, String text) => Row( + children: [ + AppIconTile(icon, size: 36), + sizedBoxW12H12, + Expanded(child: Text(text)), + ], ); Future _runAction(CatalogMedal medal, CatalogMedalAction action) async { @@ -127,13 +148,29 @@ class _MedalCenterPageState extends State { builder: (context) { final controller = TextEditingController(); return AlertDialog( - title: Text(tr.applicationTitle), - content: TextField( - controller: controller, - autofocus: true, - minLines: 3, - maxLines: 6, - decoration: InputDecoration(labelText: tr.applicationReason), + scrollable: true, + title: _dialogTitle(Icons.rate_review_outlined, tr.applicationTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + medal.name, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + sizedBoxW12H12, + TextField( + controller: controller, + autofocus: true, + minLines: 3, + maxLines: 6, + decoration: InputDecoration( + labelText: tr.applicationReason, + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), + ), + ], ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.t.general.cancel)), @@ -155,9 +192,32 @@ class _MedalCenterPageState extends State { await showDialog( context: context, builder: (context) => AlertDialog( - title: Text(tr.purchaseTitle), - content: Text( - confirmText?.isNotEmpty ?? false ? confirmText! : tr.purchaseConfirm(name: medal.name), + scrollable: true, + title: _dialogTitle(Icons.shopping_cart_outlined, tr.purchaseTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _image(medal.imageUrl, size: 48), + sizedBoxW12H12, + Expanded( + child: Text( + medal.name, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], + ), + sizedBoxW12H12, + AppInsetBlock( + outlined: true, + child: Text( + confirmText?.isNotEmpty ?? false ? confirmText! : tr.purchaseConfirm(name: medal.name), + ), + ), + ], ), actions: [ TextButton( @@ -205,6 +265,172 @@ class _MedalCenterPageState extends State { return button; } + /// Name, image and method of a medal; tapping it shows the details and the acquisition actions. + Widget _medalCard(MedalCenterState state, CatalogMedal medal, TranslationsMedalCenterEn tr) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // The reasons of disabled actions are shown as text too: a tooltip alone is hard to find on touch screens. + final reasons = { + for (final action in medal.actions) + if (action.disabledReason?.isNotEmpty ?? false) ?action.disabledReason, + }; + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: ExpansionTile( + key: ValueKey('${state.url}-${medal.id}'), + collapsedShape: const Border(), + shape: const Border(), + tilePadding: const EdgeInsets.fromLTRB(12, 4, 12, 4), + leading: _image(medal.imageUrl), + title: Text(medal.name, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + subtitle: Text( + medal.method.isEmpty ? tr.notProvided : medal.method, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + expandedCrossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (medal.description.isNotEmpty) ...[ + Text(medal.description, style: textTheme.bodyMedium), + sizedBoxW8H8, + ], + if (medal.accountStatus != null) ...[ + AppNoticeBanner(message: medal.accountStatus!, icon: Icons.person_outline), + sizedBoxW8H8, + ], + AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (medal.details.isEmpty) + Text(tr.noDetails, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + for (final (index, detail) in medal.details.indexed) + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 3), + child: Icon(Icons.chevron_right, size: 16, color: colorScheme.primary), + ), + sizedBoxW4H4, + Expanded(child: Text(detail)), + ], + ), + ), + ], + ), + ), + if (medal.actions.isNotEmpty) ...[ + sizedBoxW12H12, + Wrap( + spacing: 8, + runSpacing: 8, + children: [for (final action in medal.actions) _actionButton(medal, action, tr)], + ), + ], + for (final reason in reasons) ...[ + sizedBoxW8H8, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: colorScheme.error), + sizedBoxW4H4, + Expanded( + child: Text(reason, style: textTheme.bodySmall?.copyWith(color: colorScheme.error)), + ), + ], + ), + ], + ], + ), + ); + } + + /// Intro or search heading and the category picker. + Widget _header(MedalCenterState state, MedalCatalog? catalog, MedalCategory? selected, TranslationsMedalCenterEn tr) { + final query = state.query; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (query == null) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppIconTile(Icons.military_tech_outlined, size: 36), + sizedBoxW12H12, + Expanded( + child: Text( + tr.browserNotice, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ) + else + AppSectionHeader( + tr.searchResults(query: query), + icon: Icons.search, + padding: EdgeInsets.zero, + ), + if (catalog?.categories.isNotEmpty ?? false) ...[ + sizedBoxW12H12, + DropdownButtonFormField( + key: ValueKey('category-${state.url}|$query'), + initialValue: selected?.url, + isExpanded: true, + menuMaxHeight: 400, + borderRadius: BorderRadius.circular(appInnerRadius), + hint: query == null ? null : Text(tr.allResults, overflow: TextOverflow.ellipsis), + decoration: appPickerDecoration(context, label: tr.category, icon: Icons.category_outlined), + items: [ + for (final category in catalog!.categories) + DropdownMenuItem( + value: category.url, + child: Text(category.name, overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (url) { + if (url != null) unawaited(_cubit.load(url)); + }, + ), + ], + ], + ), + ); + } + + Widget _pager(MedalCatalog catalog, TranslationsMedalCenterEn tr) => Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + TextButton.icon( + onPressed: catalog.previousUrl == null ? null : () => _cubit.load(catalog.previousUrl), + icon: const Icon(Icons.chevron_left), + label: Text(tr.previous), + ), + AppInsetBlock( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text(tr.page(number: catalog.page)), + ), + TextButton( + onPressed: catalog.nextUrl == null ? null : () => _cubit.load(catalog.nextUrl), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text(tr.next), sizedBoxW4H4, const Icon(Icons.chevron_right, size: 18)], + ), + ), + ], + ); + @override Widget build(BuildContext context) => BlocConsumer( bloc: _cubit, @@ -239,13 +465,16 @@ class _MedalCenterPageState extends State { ), ); } else if (state.needLogin) { - body = Center( - child: TextButton( + body = AppStateView( + icon: Icons.login, + message: tr.loginRequired, + action: FilledButton.tonalIcon( onPressed: () async { await context.pushNamed(ScreenPaths.login); if (mounted) await _cubit.load(); }, - child: Text(tr.loginRequired), + icon: const Icon(Icons.login), + label: Text(context.t.loginPage.login), ), ); } else if (state.failed) { @@ -255,97 +484,59 @@ class _MedalCenterPageState extends State { message: query == null ? context.t.general.failedToLoad : tr.searchFailed(query: query), ); } else { + final medals = catalog?.medals ?? const []; body = RefreshIndicator( onRefresh: _cubit.load, - child: ListView( - key: ValueKey('${state.url}|$query'), - padding: const EdgeInsets.all(12), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - if (query == null) - Text(tr.browserNotice) - else - Text(tr.searchResults(query: query), style: Theme.of(context).textTheme.titleSmall), - if (catalog?.categories.isNotEmpty ?? false) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: DropdownButtonFormField( - key: ValueKey('category-${state.url}|$query'), - initialValue: selected?.url, - isExpanded: true, - menuMaxHeight: 400, - hint: query == null ? null : Text(tr.allResults, overflow: TextOverflow.ellipsis), - decoration: InputDecoration(labelText: tr.category), - items: [ - for (final category in catalog!.categories) - DropdownMenuItem( - value: category.url, - child: Text(category.name, overflow: TextOverflow.ellipsis), - ), - ], - onChanged: (url) { - if (url != null) unawaited(_cubit.load(url)); - }, - ), - ), - if (catalog?.supported == false) - Text(catalog!.message?.isNotEmpty ?? false ? catalog.message! : tr.unsupported) - else if (catalog?.medals.isEmpty ?? true) - Padding( - padding: const EdgeInsets.all(24), - child: Text( - [ - if (query != null) tr.searchEmpty(query: query), - if (catalog?.message?.isNotEmpty ?? false) catalog!.message! else if (query == null) tr.empty, - ].join('\n'), - ), - ), - for (final medal in catalog?.medals ?? []) - Card( - child: ExpansionTile( - key: ValueKey('${state.url}-${medal.id}'), - collapsedShape: const Border(), - shape: const Border(), - leading: _image(medal.imageUrl), - title: Text(medal.name), - subtitle: Text(medal.method.isEmpty ? tr.notProvided : medal.method), - childrenPadding: const EdgeInsets.all(16), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (medal.description.isNotEmpty) Text(medal.description), - if (medal.accountStatus != null) Text(medal.accountStatus!), - if (medal.details.isEmpty) Text(tr.noDetails), - for (final detail in medal.details) - Padding(padding: const EdgeInsets.symmetric(vertical: 6), child: Text(detail)), - if (medal.actions.isNotEmpty) - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final action in medal.actions) _actionButton(medal, action, tr), - ], - ), - ], - ), - ), - if (catalog != null && catalog.supported) - Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 12, - children: [ - TextButton( - onPressed: catalog.previousUrl == null ? null : () => _cubit.load(catalog.previousUrl), - child: Text(tr.previous), + child: AppCenteredList( + builder: (context, padding, width) { + final columns = appColumnsFor(width); + final gap = width < 600 ? appSurfaceGapCompact : appSurfaceGap; + return ListView( + key: ValueKey('${state.url}|$query'), + padding: padding.copyWith(top: 8, bottom: 12), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + _header(state, catalog, selected, tr), + SizedBox(height: gap), + if (catalog?.supported == false) + AppStateView( + icon: Icons.military_tech_outlined, + message: catalog!.message?.isNotEmpty ?? false ? catalog.message! : tr.unsupported, + scrollable: false, + action: TextButton.icon( + onPressed: () async => context.dispatchAsUrl(state.url, external: true), + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(tr.openBrowser), + ), + ) + else if (medals.isEmpty) + AppStateView( + icon: query == null ? Icons.military_tech_outlined : Icons.search_off_outlined, + scrollable: false, + message: [ + if (query != null) tr.searchEmpty(query: query), + if (catalog?.message?.isNotEmpty ?? false) catalog!.message! else if (query == null) tr.empty, + ].join('\n'), ), - Text(tr.page(number: catalog.page)), - TextButton( - onPressed: catalog.nextUrl == null ? null : () => _cubit.load(catalog.nextUrl), - child: Text(tr.next), + for (var row = 0; row < appRowCount(medals.length, columns); row++) + Padding( + padding: EdgeInsets.only(top: row == 0 ? 0 : gap), + child: AppColumnsRow( + row: row, + columns: columns, + count: medals.length, + gap: gap, + itemBuilder: (context, index) => _medalCard(state, medals[index], tr), + ), ), - ], - ), - ], + if (catalog != null && catalog.supported) + Padding( + padding: EdgeInsets.only(top: gap), + child: _pager(catalog, tr), + ), + ], + ); + }, ), ); } diff --git a/lib/features/medal_center/view/medal_title_hub_page.dart b/lib/features/medal_center/view/medal_title_hub_page.dart new file mode 100644 index 000000000..e8454ae77 --- /dev/null +++ b/lib/features/medal_center/view/medal_title_hub_page.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// Common entry of the medal centre, the titles of the current account and the title shop. +/// +/// Testers found the title shop only behind avatar > profile > menu > my titles > shop. This page links the three +/// existing pages side by side, each one keeps its own route, flow and confirmations; nothing is bought or switched +/// here. +class MedalTitleHubPage extends StatelessWidget { + /// Constructor. + const MedalTitleHubPage({super.key}); + + @override + Widget build(BuildContext context) { + final tr = context.t.medalTitleHub; + final uid = context.readOrNull()?.currentUser?.uid; + return Scaffold( + appBar: AppBar(title: Text(tr.title)), + body: SafeArea( + top: false, + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: appFormMaxWidth), + child: ListView( + padding: edgeInsetsL16T16R16B16, + children: [ + _CurrentTitleCard(uid: uid), + sizedBoxW16H16, + _HubDestination( + icon: Icons.workspace_premium_outlined, + title: context.t.medalCenter.title, + description: tr.medalCenterDescription, + path: ScreenPaths.medalCenter, + ), + sizedBoxW12H12, + _HubDestination( + icon: Icons.badge_outlined, + title: context.t.myTitlesPage.title, + description: tr.myTitlesDescription, + path: ScreenPaths.switchTitle, + ), + sizedBoxW12H12, + _HubDestination( + icon: Icons.shopping_bag_outlined, + title: context.t.titleShop.title, + description: tr.titleShopDescription, + path: ScreenPaths.titleShop, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// The title the current account uses, read once per account. +class _CurrentTitleCard extends StatelessWidget { + const _CurrentTitleCard({required this.uid}); + + final int? uid; + + @override + Widget build(BuildContext context) { + final tr = context.t.medalTitleHub; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final cubit = context.readOrNull(); + final info = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr.currentTitle, style: textTheme.titleMedium), + sizedBoxW4H4, + if (cubit != null && uid != null) + BlocBuilder( + bloc: cubit, + builder: (context, state) { + final String text; + if (state.uid != uid) { + text = ''; + } else { + text = switch (state.status) { + CurrentTitleStatus.success => state.title?.name ?? tr.noTitle, + CurrentTitleStatus.failure => tr.loadFailed, + CurrentTitleStatus.initial || CurrentTitleStatus.loading => state.title?.name ?? '', + }; + } + return Text(text, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)); + }, + ), + ], + ); + return AppSurface( + color: colorScheme.surfaceContainerLow, + padding: edgeInsetsL16T16R16B16, + child: LayoutBuilder( + builder: (context, constraints) { + // The title at its natural 184px width beside the text when there is room, below it otherwise. + final badgeWidth = SecondaryTitleBadge.fitWidth(constraints.maxWidth); + if (constraints.maxWidth - badgeWidth - 12 >= 160) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: info), + CurrentAccountTitleBadge( + uid: uid, + width: badgeWidth, + padding: const EdgeInsetsDirectional.only(start: 12), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + info, + CurrentAccountTitleBadge(uid: uid, width: badgeWidth, padding: const EdgeInsets.only(top: 12)), + ], + ); + }, + ), + ); + } +} + +/// A card linking an existing page. +class _HubDestination extends StatelessWidget { + const _HubDestination({required this.icon, required this.title, required this.description, required this.path}); + + final IconData icon; + final String title; + final String description; + final String path; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.antiAlias, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: DecoratedBox( + decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), + child: SizedBox( + width: 44, + height: 44, + child: Icon(icon, color: colorScheme.onPrimaryContainer), + ), + ), + title: Text(title), + subtitle: Text(description), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(path), + ), + ); + } +} diff --git a/lib/features/multi_user/view/manage_account_page.dart b/lib/features/multi_user/view/manage_account_page.dart index 898c42219..399281fb6 100644 --- a/lib/features/multi_user/view/manage_account_page.dart +++ b/lib/features/multi_user/view/manage_account_page.dart @@ -23,6 +23,7 @@ import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.d import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -125,50 +126,48 @@ class _ManageAccountPageState extends State { final Widget body; if (snapshot.hasError) { // Unreachable. - body = Center(child: Text('${snapshot.error}')); + body = AppStateView(icon: Icons.error_outline, error: true, message: '${snapshot.error}'); } else if (!snapshot.hasData) { body = const CenteredCircularIndicator(); } else { - body = SingleChildScrollView( - child: Padding( - padding: edgeInsetsL12T4R12B4, - child: Card( - margin: EdgeInsets.zero, - child: Padding( - padding: edgeInsetsL12T12R12.add(context.safePadding()), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Text(tr.allUsers, style: Theme.of(context).textTheme.titleMedium), - if (busy) ...[sizedBoxW12H12, sizedCircularProgressIndicator], - ], - ), - sizedBoxW4H4, - // List all recorded users. - ...users.map( - (e) => _UserInfoListTile( - userInfo: e.user, - lastCheckin: e.lastCheckin, - sessionExpiredAt: e.sessionExpiredAt, - currentUid: currentUid, - selecting: selection.selecting, - selected: selection.selectedUids.contains(e.user.uid), - enabled: !busy, - ), - ), - ListTile( - leading: const Icon(Icons.add_outlined), - title: Text(tr.addUser), - enabled: !busy && !selection.selecting, - onTap: () async => context.pushNamed(ScreenPaths.login), + // One group of accounts, centered like the other form pages; the add row sits apart under it so it is not + // taken for an account. + body = AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 24).add(context.safePadding()), + children: [ + AppTileGroup( + title: tr.allUsers, + icon: Icons.people_outline, + trailing: busy ? sizedCircularProgressIndicator : null, + children: [ + // List all recorded users. + ...users.map( + (e) => _UserInfoListTile( + userInfo: e.user, + lastCheckin: e.lastCheckin, + sessionExpiredAt: e.sessionExpiredAt, + currentUid: currentUid, + selecting: selection.selecting, + selected: selection.selectedUids.contains(e.user.uid), + enabled: !busy, ), - ], + ), + ], + ), + const SizedBox(height: appSurfaceGap), + AppSurface( + padding: EdgeInsets.zero, + child: ListTile( + leading: const AppIconTile(Icons.person_add_alt_outlined), + title: Text(tr.addUser), + trailing: const Icon(Icons.chevron_right), + enabled: !busy && !selection.selecting, + onTap: () async => context.pushNamed(ScreenPaths.login), ), ), - ), + ], ), ); } @@ -344,10 +343,22 @@ class _UserInfoListTile extends StatelessWidget with LoggerMixin { label: Text(tr.sessionExpired.loginAgain), ); } else if (isCurrentUser) { - trailing = Chip( - side: BorderSide.none, - backgroundColor: colorScheme.secondaryContainer, - label: Text(tr.online, style: textTheme.labelMedium?.copyWith(color: colorScheme.onSecondaryContainer)), + // Same rounded pill as the other status marks of the app. + trailing = DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Text( + tr.online, + style: textTheme.labelMedium?.copyWith( + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), ); } else { trailing = null; @@ -364,12 +375,12 @@ class _UserInfoListTile extends StatelessWidget with LoggerMixin { child: const Icon(Icons.check), ) : HeroUserAvatar(username: userInfo.username!, avatarUrl: null, disableHero: true), - title: Text(userInfo.username!), + title: Text(userInfo.username!, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text('${userInfo.uid!}'), + Text('UID ${userInfo.uid!}', style: textTheme.labelSmall?.copyWith(color: colorScheme.outline)), Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/multi_user/widgets/manage_user_dialog.dart b/lib/features/multi_user/widgets/manage_user_dialog.dart index 803b05d13..3db33681b 100644 --- a/lib/features/multi_user/widgets/manage_user_dialog.dart +++ b/lib/features/multi_user/widgets/manage_user_dialog.dart @@ -17,6 +17,7 @@ import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.d import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -66,13 +67,31 @@ class _ManageUserDialog extends StatelessWidget with LoggerMixin { @override Widget build(BuildContext context) { final tr = context.t.manageAccountPage.switchAccount.dialog; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // Only this account's own name and uid: nothing of another account is shown here. return CustomAlertDialog.sync( clipBehavior: Clip.hardEdge, title: Row( children: [ HeroUserAvatar(username: userInfo.username!, avatarUrl: null, disableHero: true, minRadius: 30), sizedBoxW12H12, - Expanded(child: SingleLineText(userInfo.username!, style: Theme.of(context).textTheme.titleLarge)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleLineText(userInfo.username!, style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + Wrap( + spacing: 6, + runSpacing: 4, + children: [ + if (userInfo.uid != null) AppInfoPill(icon: Icons.tag, label: '${userInfo.uid}'), + if (isCurrentUser) AppInfoPill(icon: Icons.circle, label: context.t.manageAccountPage.online), + ], + ), + ], + ), + ), ], ), content: Column( @@ -80,6 +99,8 @@ class _ManageUserDialog extends StatelessWidget with LoggerMixin { if (isCurrentUser) ...[_buildLogoutTile(context), _buildRemoveFromDeviceTile(context)], if (!isCurrentUser) ...[ ListTile( + leading: const Icon(Icons.switch_account_outlined), + shape: _tileShape, title: Text(tr.switchAccount), onTap: () async { var times = 10; @@ -97,6 +118,27 @@ class _ManageUserDialog extends StatelessWidget with LoggerMixin { }, ), ListTile( + leading: const Icon(Icons.login_outlined), + shape: _tileShape, + title: Text(tr.loginAgain.title), + subtitle: Text(tr.loginAgain.detail), + onTap: () async { + await context.pushNamed( + ScreenPaths.login, + queryParameters: {if (userInfo.username != null) 'username': '${userInfo.username}'}, + ); + if (!context.mounted) { + return; + } + context.pop(); + }, + ), + Divider(height: 9, color: colorScheme.outlineVariant.withValues(alpha: 0.6)), + // Destructive, last and in the error color. + ListTile( + leading: Icon(Icons.person_remove_outlined, color: colorScheme.error), + shape: _tileShape, + textColor: colorScheme.error, title: Text(tr.deleteAccount.title), subtitle: Text(tr.deleteAccount.detail), enabled: userInfo.uid != null, @@ -121,33 +163,25 @@ class _ManageUserDialog extends StatelessWidget with LoggerMixin { context.pop(); }, ), - ListTile( - title: Text(tr.loginAgain.title), - subtitle: Text(tr.loginAgain.detail), - onTap: () async { - await context.pushNamed( - ScreenPaths.login, - queryParameters: {if (userInfo.username != null) 'username': '${userInfo.username}'}, - ); - if (!context.mounted) { - return; - } - context.pop(); - }, - ), ], ], ), ); } + static final _tileShape = RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)); + /// Remove the current account from this device after confirmation, without logging out of the forum. /// /// The local counterpart of [_buildLogoutTile]: works offline and when the forum session already expired, which /// left the account stuck as "online" before (issue #6). Widget _buildRemoveFromDeviceTile(BuildContext context) { final tr = context.t.manageAccountPage; + final error = Theme.of(context).colorScheme.error; return ListTile( + leading: Icon(Icons.phonelink_erase_outlined, color: error), + shape: _tileShape, + textColor: error, title: Text(tr.removeFromDevice.title), subtitle: Text(tr.removeFromDevice.detail), onTap: () async { @@ -181,6 +215,8 @@ class _ManageUserDialog extends StatelessWidget with LoggerMixin { Widget _buildLogoutTile(BuildContext context) { final tr = context.t.manageAccountPage; return ListTile( + leading: const Icon(Icons.logout_outlined), + shape: _tileShape, title: Text(tr.logout), subtitle: Text(tr.logoutDetail), onTap: () async { diff --git a/lib/features/my_thread/view/my_thread_page.dart b/lib/features/my_thread/view/my_thread_page.dart index c5a4da8d2..8e6ad2a0c 100644 --- a/lib/features/my_thread/view/my_thread_page.dart +++ b/lib/features/my_thread/view/my_thread_page.dart @@ -1,12 +1,12 @@ import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/draft_box/view/draft_box_panel.dart'; import 'package:tsdm_client/features/my_thread/bloc/my_thread_bloc.dart'; import 'package:tsdm_client/features/my_thread/repository/my_thread_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/thread_card/thread_card.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -37,20 +37,17 @@ class _MyThreadPageState extends State with SingleTickerProviderSt ..finishLoad(); final Widget child; if (state.threadList.isEmpty) { - child = Center( - child: Text( - context.t.general.noData, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ); + child = AppStateView(icon: Icons.article_outlined, message: context.t.general.noData); } else { - child = ListView.separated( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.threadList.length, - itemBuilder: (context, index) { - return MyThreadCard(state.threadList[index]); - }, - separatorBuilder: (context, index) => sizedBoxW4H4, + child = AppCenteredList( + builder: (context, side, _) => ListView.separated( + padding: side.copyWith(top: 8).add(context.safePadding()), + itemCount: state.threadList.length, + itemBuilder: (context, index) { + return MyThreadCard(state.threadList[index]); + }, + separatorBuilder: (context, index) => appListSeparator, + ), ); } return EasyRefresh( @@ -80,20 +77,17 @@ class _MyThreadPageState extends State with SingleTickerProviderSt ..finishLoad(); final Widget child; if (state.replyList.isEmpty) { - child = Center( - child: Text( - context.t.general.noData, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ); + child = AppStateView(icon: Icons.article_outlined, message: context.t.general.noData); } else { - child = ListView.separated( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.replyList.length, - itemBuilder: (context, index) { - return MyThreadCard(state.replyList[index]); - }, - separatorBuilder: (context, index) => sizedBoxW4H4, + child = AppCenteredList( + builder: (context, side, _) => ListView.separated( + padding: side.copyWith(top: 8).add(context.safePadding()), + itemCount: state.replyList.length, + itemBuilder: (context, index) { + return MyThreadCard(state.replyList[index]); + }, + separatorBuilder: (context, index) => appListSeparator, + ), ); } diff --git a/lib/features/need_login/view/need_login_page.dart b/lib/features/need_login/view/need_login_page.dart index c8970392b..285345185 100644 --- a/lib/features/need_login/view/need_login_page.dart +++ b/lib/features/need_login/view/need_login_page.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; -import 'package:tsdm_client/widgets/card/error_card.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// A page to show need to login hint. /// @@ -40,35 +40,31 @@ class NeedLoginPage extends StatelessWidget { @override Widget build(BuildContext context) { + // Same state view as the other empty and locked pages: icon block, message, one login action. return Scaffold( appBar: showAppBar ? AppBar(title: Text(context.t.appName)) : null, - body: ErrorCard( - message: t.general.needLoginToSeeThisPage, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: Row( - children: [ - Expanded( - child: FilledButton( - child: Text(t.loginPage.login), - onPressed: () async { - await context.pushNamed(ScreenPaths.login); - if (!context.mounted) { - return; - } - if (needPop) { - await popCallback?.call(context); - if (!context.mounted) { - return; - } - context.pushReplacement(backUri.toString()); - } else { - await context.push(backUri.toString()); - } - }, - ), - ), - ], + body: SafeArea( + child: AppStateView( + icon: Icons.lock_person_outlined, + message: t.general.needLoginToSeeThisPage, + action: FilledButton.icon( + icon: const Icon(Icons.login_outlined), + label: Text(t.loginPage.login), + onPressed: () async { + await context.pushNamed(ScreenPaths.login); + if (!context.mounted) { + return; + } + if (needPop) { + await popCallback?.call(context); + if (!context.mounted) { + return; + } + context.pushReplacement(backUri.toString()); + } else { + await context.push(backUri.toString()); + } + }, ), ), ), diff --git a/lib/features/notification/view/broadcast_message_detail_page.dart b/lib/features/notification/view/broadcast_message_detail_page.dart index 72bf933e9..4923c5126 100644 --- a/lib/features/notification/view/broadcast_message_detail_page.dart +++ b/lib/features/notification/view/broadcast_message_detail_page.dart @@ -11,6 +11,7 @@ import 'package:tsdm_client/features/notification/repository/notification_reposi import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/html/html_muncher.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -23,20 +24,48 @@ final class BroadcastMessageDetailPage extends StatelessWidget { final String pmid; Widget _buildBody(BuildContext context, BroadcastMessageDetailState state) { - return SingleChildScrollView( - child: Padding( - padding: edgeInsetsL12R12B12.add(context.safePadding()), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)), - title: SingleLineText(context.t.noticePage.broadcastMessageTab.system), - subtitle: Text(state.dateTime?.yyyyMMDD() ?? ''), - ), - sizedBoxW4H4, - Padding(padding: edgeInsetsL16R16, child: munchElement(context, state.messageNode!)), - ], + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // One reading surface: sender and date as the header, the message below, centered at a readable width. + return AppCenteredList( + maxWidth: appReadingMaxWidth, + builder: (context, side, _) => SingleChildScrollView( + padding: side.copyWith(top: 8, bottom: 12).add(context.safePadding()), + child: AppSurface( + padding: edgeInsetsL16T16R16B16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + AppIconTile( + Icons.campaign_outlined, + color: colorScheme.tertiaryContainer, + foregroundColor: colorScheme.onTertiaryContainer, + ), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleLineText( + context.t.noticePage.broadcastMessageTab.system, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + if (state.dateTime != null) + Text( + state.dateTime!.yyyyMMDD(), + style: textTheme.labelSmall?.copyWith(color: colorScheme.outline), + ), + ], + ), + ), + ], + ), + const Divider(height: 24), + munchElement(context, state.messageNode!), + ], + ), ), ), ); diff --git a/lib/features/notification/view/notification_detail_page.dart b/lib/features/notification/view/notification_detail_page.dart index 3b9d3989d..07629ee2b 100644 --- a/lib/features/notification/view/notification_detail_page.dart +++ b/lib/features/notification/view/notification_detail_page.dart @@ -15,6 +15,7 @@ import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.d import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/back_to_home_button.dart'; import 'package:tsdm_client/widgets/card/post_card/post_card.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -57,8 +58,24 @@ class _NoticeDetailPage extends State with LoggerMixin { // Post can not be null because we only call this function when in // success state. final post = state.post!; + // The post as a floor of the thread reader: a rounded surface at a readable width, phones keep most of the width + // for the text. + final side = MediaQuery.sizeOf(context).width < 600 ? 6.0 : 16.0; + // Only the post keeps the side safe area: the reply bar pads the insets itself so its background reaches the + // screen edges in landscape. + final postView = SafeArea( + top: false, + bottom: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(side, 8, side, 12), + child: AppContentWidth( + maxWidth: appReadingMaxWidth, + child: Card(margin: EdgeInsets.zero, shape: appSurfaceShape(context), child: PostCard(post)), + ), + ), + ); if (widget.noticeType == NoticeType.rate) { - return SingleChildScrollView(child: PostCard(post)); + return postView; } if (state.replyParameters != null) { @@ -68,7 +85,7 @@ class _NoticeDetailPage extends State with LoggerMixin { return Column( children: [ - Expanded(child: SingleChildScrollView(child: PostCard(post))), + Expanded(child: postView), // Always show reply bar even thread is locked to keep the same UI as // visiting locked thread. ReplyBar( @@ -131,9 +148,13 @@ class _NoticeDetailPage extends State with LoggerMixin { final body = switch (state.status) { NotificationDetailStatus.initial || NotificationDetailStatus.loading => const CenteredCircularIndicator(), NotificationDetailStatus.success => _buildBody(context, state), - NotificationDetailStatus.failed => buildRetryButton(context, () async { - await context.read().fetchDetail(widget.url); - }), + NotificationDetailStatus.failed => SafeArea( + top: false, + bottom: false, + child: buildRetryButton(context, () async { + await context.read().fetchDetail(widget.url); + }), + ), }; // Update thread closed state to reply bar. @@ -170,6 +191,7 @@ class _NoticeDetailPage extends State with LoggerMixin { ), ], ), + // The post and the retry keep the side safe area; the reply bar pads itself for all its insets. body: body, ); }, diff --git a/lib/features/notification/view/notification_page.dart b/lib/features/notification/view/notification_page.dart index afc9868cb..69720baec 100644 --- a/lib/features/notification/view/notification_page.dart +++ b/lib/features/notification/view/notification_page.dart @@ -13,11 +13,14 @@ import 'package:tsdm_client/features/notification/bloc/auto_notification_cubit.d import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; import 'package:tsdm_client/features/notification/bloc/notification_state_cubit.dart'; import 'package:tsdm_client/features/notification/bloc/notification_sync_all_cubit.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/notification_type.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/back_to_home_button.dart'; import 'package:tsdm_client/widgets/card/notice_card_v2.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -42,20 +45,60 @@ class _NotificationPageState extends State with SingleTickerPr /// Flag indicating only show unread messages or not bool onlyShowUnread = false; - Widget _buildEmptyBody(ScrollPhysics physics) { - return Align( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - physics: physics, - child: ConstrainedBox( - constraints: BoxConstraints(minWidth: MediaQuery.sizeOf(context).width, minHeight: constraints.maxHeight), - child: Center( - child: Text( - context.t.general.noData, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), + /// One tab of the page: a centered list of [count] cards built by [itemBuilder], or the empty state; both keep the + /// pull to refresh. + Widget _buildTab( + BuildContext context, { + required EasyRefreshController controller, + required int count, + required IconData emptyIcon, + required IndexedWidgetBuilder itemBuilder, + }) { + return EasyRefresh.builder( + controller: controller, + header: const MaterialHeader(), + onRefresh: () => context.read().add(NotificationUpdateAllRequested()), + childBuilder: (context, physics) => count == 0 + ? AppScrollableStateView( + physics: physics, + child: AppStateView(icon: emptyIcon, message: context.t.general.noData, scrollable: false), + ) + : AppCenteredList( + builder: (context, side, _) => ListView.separated( + physics: physics, + padding: side.copyWith(top: 4, bottom: 12).add(context.safePadding()), + itemCount: count, + itemBuilder: itemBuilder, + separatorBuilder: (_, _) => appListSeparator, ), ), - ), + ); + } + + /// "Unread only" toggle in the app bar, applied to all three tabs. + /// + /// It used to take a row of its own above the tabs, mostly empty (feedback 110). Selected, it gets a tonal + /// background and the filled icon; the tooltip and the semantics name it and tell its state. + Widget _buildUnreadFilterButton(BuildContext context) { + final tr = context.t.noticePage; + final colorScheme = Theme.of(context).colorScheme; + // Merged so the toggled state belongs to the button's own node (its tooltip is the label). + return MergeSemantics( + child: Semantics( + toggled: onlyShowUnread, + child: IconButton( + key: const ValueKey('notice-unread-filter'), + isSelected: onlyShowUnread, + icon: const Icon(Icons.mark_email_unread_outlined), + selectedIcon: const Icon(Icons.mark_email_unread), + tooltip: '${tr.appBar.unread} · ${tr.appBar.unreadDetail}', + style: onlyShowUnread + ? IconButton.styleFrom( + backgroundColor: colorScheme.secondaryContainer, + foregroundColor: colorScheme.onSecondaryContainer, + ) + : null, + onPressed: () => setState(() => onlyShowUnread = !onlyShowUnread), ), ), ); @@ -113,59 +156,58 @@ class _NotificationPageState extends State with SingleTickerPr false => (noticeList, state.personalMessageList, state.broadcastMessageList), }; + // Unread counters on the tabs: same rules as the badge above (muted conversations are not counted), and + // only for the kinds whose unread badge is enabled in settings. + // Settings are only read once there is something to count, like the cards do. + final settings = state.status == NotificationStatus.success + ? getIt.get().currentSettings + : null; + final unreadCounts = settings != null + ? ( + settings.showUnreadNoticeBadge ? noticeList.where((e) => !e.alreadyRead).length : 0, + settings.showUnreadPersonalMessageBadge + ? state.personalMessageList + .where((e) => !e.alreadyRead && !isMutedPersonalMessagePeer(e.peerUid, blocked)) + .length + : 0, + settings.showUnreadBroadcastMessageBadge + ? state.broadcastMessageList.where((e) => !e.alreadyRead).length + : 0, + ) + : (0, 0, 0); + final body = switch (state.status) { NotificationStatus.initial || NotificationStatus.loading => const CenteredCircularIndicator(), NotificationStatus.success => TabBarView( controller: _tabController, children: [ - EasyRefresh.builder( + _buildTab( + context, controller: _noticeRefreshController, - header: const MaterialHeader(), - onRefresh: () => context.read().add(NotificationUpdateAllRequested()), - childBuilder: (context, physics) => n.isEmpty - ? _buildEmptyBody(physics) - : ListView.separated( - physics: physics, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: n.length, - itemBuilder: (_, idx) => - NoticeCardV2(key: ValueKey('NOTICE_${n.elementAt(idx).id}'), n.elementAt(idx)), - separatorBuilder: (_, _) => sizedBoxW4H4, - ), + count: n.length, + emptyIcon: Icons.notifications_none_outlined, + itemBuilder: (_, idx) => + NoticeCardV2(key: ValueKey('NOTICE_${n.elementAt(idx).id}'), n.elementAt(idx)), ), - EasyRefresh.builder( + _buildTab( + context, controller: _personalMessageRefreshController, - header: const MaterialHeader(), - onRefresh: () => context.read().add(NotificationUpdateAllRequested()), - childBuilder: (context, physics) => pm.isEmpty - ? _buildEmptyBody(physics) - : ListView.separated( - physics: physics, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: pm.length, - itemBuilder: (_, idx) => PersonalMessageCardV2( - key: ValueKey('PM_${pm.elementAt(idx).timestamp}'), - pm.elementAt(idx), - ), - separatorBuilder: (_, _) => sizedBoxW4H4, - ), + count: pm.length, + emptyIcon: Icons.forum_outlined, + itemBuilder: (_, idx) => PersonalMessageCardV2( + key: ValueKey('PM_${pm.elementAt(idx).timestamp}'), + pm.elementAt(idx), + ), ), - EasyRefresh.builder( + _buildTab( + context, controller: _broadcastMessageRefreshController, - header: const MaterialHeader(), - onRefresh: () => context.read().add(NotificationUpdateAllRequested()), - childBuilder: (context, physics) => bm.isEmpty - ? _buildEmptyBody(physics) - : ListView.separated( - physics: physics, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: bm.length, - itemBuilder: (_, idx) => BroadcastMessageCardV2( - key: ValueKey('BM_${bm.elementAt(idx).timestamp}'), - bm.elementAt(idx), - ), - separatorBuilder: (_, _) => sizedBoxW4H4, - ), + count: bm.length, + emptyIcon: Icons.campaign_outlined, + itemBuilder: (_, idx) => BroadcastMessageCardV2( + key: ValueKey('BM_${bm.elementAt(idx).timestamp}'), + bm.elementAt(idx), + ), ), ], ), @@ -178,15 +220,8 @@ class _NotificationPageState extends State with SingleTickerPr appBar: AppBar( title: Text(tr.title), actions: [ + if (state.status == NotificationStatus.success) _buildUnreadFilterButton(context), const BackToHomeButton(), - FilterChip( - label: Text(tr.appBar.unread), - tooltip: tr.appBar.unreadDetail, - selected: onlyShowUnread, - onSelected: (_) { - setState(() => onlyShowUnread = !onlyShowUnread); - }, - ), IconButton( icon: const Icon(Icons.block_outlined), tooltip: context.t.userBlock.manageEntry, @@ -229,6 +264,7 @@ class _NotificationPageState extends State with SingleTickerPr ], ), ), + const PopupMenuDivider(), PopupMenuItem( value: _Actions.syncAllAccounts, child: Row( @@ -260,7 +296,7 @@ class _NotificationPageState extends State with SingleTickerPr }, ), ], - bottom: _PreferredSizeComponentBottom(_tabController), + bottom: _PreferredSizeComponentBottom(_tabController, unreadCounts), ), body: SafeArea(bottom: false, child: body), ); @@ -272,13 +308,17 @@ class _NotificationPageState extends State with SingleTickerPr /// Composition of [TabBar] and [LinearProgressIndicator]. class _PreferredSizeComponentBottom extends StatelessWidget implements PreferredSizeWidget { - const _PreferredSizeComponentBottom(this.tabController); + const _PreferredSizeComponentBottom(this.tabController, this.unreadCounts); final TabController tabController; + /// Unread notices, personal messages and broadcast messages shown next to the tab titles, 0 to hide. + final (int, int, int) unreadCounts; + @override Widget build(BuildContext context) { final tr = context.t.noticePage; + final (n, pm, bm) = unreadCounts; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -302,9 +342,9 @@ class _PreferredSizeComponentBottom extends StatelessWidget implements Preferred TabBar( controller: tabController, tabs: [ - Tab(child: Text(tr.noticeTab.title)), - Tab(child: Text(tr.privateMessageTab.title)), - Tab(child: Text(tr.broadcastMessageTab.title)), + Tab(child: _TabLabel(tr.noticeTab.title, n)), + Tab(child: _TabLabel(tr.privateMessageTab.title, pm)), + Tab(child: _TabLabel(tr.broadcastMessageTab.title, bm)), ], ), ], @@ -315,3 +355,36 @@ class _PreferredSizeComponentBottom extends StatelessWidget implements Preferred @override Size get preferredSize => const Size.fromHeight(46 + 2); } + +/// Title of a tab with the number of unread items, if any. +class _TabLabel extends StatelessWidget { + const _TabLabel(this.title, this.unread); + + final String title; + + final int unread; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis)), + if (unread > 0) ...[ + sizedBoxW4H4, + DecoratedBox( + decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(appInnerRadius)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + child: Text( + unread > 99 ? '99+' : '$unread', + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onPrimary), + ), + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/notification/view/notification_search_page.dart b/lib/features/notification/view/notification_search_page.dart index afba2d70c..6a11184cd 100644 --- a/lib/features/notification/view/notification_search_page.dart +++ b/lib/features/notification/view/notification_search_page.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tsdm_client/constants/layout.dart'; -import 'package:tsdm_client/extensions/list.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/blocking/utils/block_filter.dart'; import 'package:tsdm_client/features/blocking/utils/notice_block_filter.dart'; import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; import 'package:tsdm_client/features/notification/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/notice_card_v2.dart'; /// Gather all kinds of notifications. @@ -42,6 +42,15 @@ class _NotificationSearchPageState extends State { var _searchContent = ''; + /// A titled group of results, nothing when [cards] is empty. + List _section(String title, IconData icon, List cards) => [ + if (cards.isNotEmpty) ...[ + AppSectionHeader(title, icon: icon, trailing: Text('${cards.length}')), + for (final (index, card) in cards.indexed) ...[if (index > 0) appListSeparator, card], + appListSeparator, + ], + ]; + @override Widget build(BuildContext context) { if (notice == null) { @@ -54,13 +63,30 @@ class _NotificationSearchPageState extends State { } final tr = context.t.noticeSearchPage; + final trNotice = context.t.noticePage; // Filtered on every build (not in the snapshot): blocking or unblocking while this page is open applies at once. final blocked = currentBlockList(context); + final notices = notice!.noticeList + .where((e) => !isBlockedNoticeAuthor(e.authorId, blocked) && e.data.contains(_searchContent)) + .map(NoticeCardV2.new) + .toList(); + final personalMessages = notice!.personalMessageList + .where((e) => e.data.contains(_searchContent)) + .map(PersonalMessageCardV2.new) + .toList(); + final broadcastMessages = notice!.broadcastMessageList + .where((e) => e.data.contains(_searchContent)) + .map(BroadcastMessageCardV2.new) + .toList(); + final empty = notices.isEmpty && personalMessages.isEmpty && broadcastMessages.isEmpty; + return Scaffold( appBar: AppBar( title: SearchBar( autoFocus: true, hintText: tr.title, + leading: const Icon(Icons.search_outlined), + elevation: const WidgetStatePropertyAll(0), onChanged: (str) { setState(() { _searchContent = str; @@ -68,15 +94,20 @@ class _NotificationSearchPageState extends State { }, ), ), - body: ListView( - padding: edgeInsetsL12T4R12B4, - children: [ - ...notice!.noticeList - .where((e) => !isBlockedNoticeAuthor(e.authorId, blocked) && e.data.contains(_searchContent)) - .map(NoticeCardV2.new), - ...notice!.personalMessageList.where((e) => e.data.contains(_searchContent)).map(PersonalMessageCardV2.new), - ...notice!.broadcastMessageList.where((e) => e.data.contains(_searchContent)).map(BroadcastMessageCardV2.new), - ].insertBetween(sizedBoxW4H4), + body: SafeArea( + bottom: false, + child: empty + ? AppStateView(icon: Icons.search_off_outlined, message: context.t.general.noData) + : AppCenteredList( + builder: (context, side, _) => ListView( + padding: side.copyWith(top: 4, bottom: 12).add(context.safePadding()), + children: [ + ..._section(trNotice.noticeTab.title, Icons.notifications_outlined, notices), + ..._section(trNotice.privateMessageTab.title, Icons.forum_outlined, personalMessages), + ..._section(trNotice.broadcastMessageTab.title, Icons.campaign_outlined, broadcastMessages), + ], + ), + ), ), ); } diff --git a/lib/features/notification/view/notification_sync_all_page.dart b/lib/features/notification/view/notification_sync_all_page.dart index ddcac5424..ed4be72b0 100644 --- a/lib/features/notification/view/notification_sync_all_page.dart +++ b/lib/features/notification/view/notification_sync_all_page.dart @@ -2,18 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; -import 'package:tsdm_client/extensions/list.dart'; -import 'package:tsdm_client/features/checkin/widgets/auto_checkin_user_card.dart'; import 'package:tsdm_client/features/notification/bloc/notification_sync_all_cubit.dart'; import 'package:tsdm_client/features/notification/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; -import 'package:tsdm_client/widgets/indicator.dart'; -import 'package:tsdm_client/widgets/tips.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Page showing the progress and the result of the sync of the notifications of all accounts. /// -/// Cloned from `AutoCheckinPage`: one card per account, running first, then waiting, then done with the result. +/// A summary on top (what the sync does, how many accounts are running, waiting and done), then one section per +/// state: running first, then waiting, then done with the result of each account. class NotificationSyncAllPage extends StatelessWidget { /// Constructor. const NotificationSyncAllPage({super.key}); @@ -67,36 +65,200 @@ class NotificationSyncAllPage extends StatelessWidget { finishedList = results; empty = results.isEmpty; } + final busy = preparing || runningList.isNotEmpty || waitingList.isNotEmpty; return Scaffold( appBar: AppBar(title: Text(tr.title)), body: SafeArea( bottom: false, - child: ListView( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - children: [ - Tips(tr.detail, enablePadding: false), - if (preparing) const CenteredCircularIndicator(), - if (empty) Center(child: Text(tr.empty)), - ...runningList.map((e) => AutoCheckinUserCard(e, tr.user.running)), - ...waitingList.map((e) => AutoCheckinUserCard(e, tr.user.waiting)), - ...finishedList.map( - (e) => AutoCheckinUserCard( - // Ok to use record. - // ignore: avoid_positional_fields_in_records - e.$1, - // Ok to use record. - // ignore: avoid_positional_fields_in_records - message(context, e.$2), - // Ok to use record. - // ignore: avoid_positional_fields_in_records - failure: e.$2 is! NotificationSyncResultSuccess, + child: AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, side, _) => ListView( + padding: side.copyWith(top: 8, bottom: 12).add(context.safePadding()), + children: [ + _SummarySurface( + detail: tr.detail, + busy: busy, + counts: [ + (Icons.sync_outlined, tr.running, runningList.length), + (Icons.hourglass_empty_outlined, tr.user.waiting, waitingList.length), + (Icons.task_alt_outlined, tr.finished, finishedList.length), + ], ), - ), - ].insertBetween(sizedBoxW8H8), + if (empty) ...[ + sizedBoxW12H12, + AppStateView(icon: Icons.person_off_outlined, message: tr.empty, scrollable: false), + ], + ..._section( + tr.running, + Icons.sync_outlined, + runningList.map((e) => _AccountSurface(e, tr.user.running, state: _AccountState.running)), + ), + ..._section( + tr.user.waiting, + Icons.hourglass_empty_outlined, + waitingList.map((e) => _AccountSurface(e, tr.user.waiting, state: _AccountState.waiting)), + ), + ..._section( + tr.finished, + Icons.task_alt_outlined, + finishedList.map( + (e) => _AccountSurface( + // Ok to use record. + // ignore: avoid_positional_fields_in_records + e.$1, + // Ok to use record. + // ignore: avoid_positional_fields_in_records + message(context, e.$2), + // Ok to use record. + // ignore: avoid_positional_fields_in_records + state: e.$2 is NotificationSyncResultSuccess ? _AccountState.done : _AccountState.failed, + ), + ), + ), + ], + ), ), ), ); }, ); } + + /// A titled group of account surfaces, nothing when [cards] is empty. + static List _section(String title, IconData icon, Iterable cards) { + final list = cards.toList(); + if (list.isEmpty) { + return const []; + } + return [ + AppSectionHeader(title, icon: icon, padding: const EdgeInsets.only(top: 16, bottom: 8)), + for (final (index, card) in list.indexed) ...[if (index > 0) appListSeparator, card], + ]; + } +} + +/// Explanation of the sync and the number of accounts in each state. +class _SummarySurface extends StatelessWidget { + const _SummarySurface({required this.detail, required this.busy, required this.counts}); + + final String detail; + + /// Show a progress bar. + final bool busy; + + /// Icon, name and number of accounts of each state. + final List<(IconData, String, int)> counts; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return AppSurface( + padding: edgeInsetsL16T16R16B16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppIconTile(Icons.sync_outlined), + sizedBoxW12H12, + Expanded( + child: Text( + detail, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + sizedBoxW12H12, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Ok to use record. + // ignore: avoid_positional_fields_in_records + for (final e in counts) AppInfoPill(icon: e.$1, label: '${e.$2} ${e.$3}'), + ], + ), + if (busy) ...[ + sizedBoxW12H12, + ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: const LinearProgressIndicator(minHeight: 4), + ), + ], + ], + ), + ); + } +} + +enum _AccountState { running, waiting, done, failed } + +/// One account in the sync: avatar letter, name and uid, state icon and the message of its state or result. +class _AccountSurface extends StatelessWidget { + const _AccountSurface(this.userInfo, this.message, {required this.state}); + + final UserLoginInfo userInfo; + + final String message; + + final _AccountState state; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final failed = state == _AccountState.failed; + final name = userInfo.username ?? '${userInfo.uid ?? ''}'; + final stateIcon = switch (state) { + _AccountState.running => const SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2)), + _AccountState.waiting => Icon(Icons.hourglass_empty_outlined, size: 20, color: colorScheme.outline), + _AccountState.done => Icon(Icons.check_circle_outline, size: 20, color: colorScheme.primary), + _AccountState.failed => Icon(Icons.error_outline, size: 20, color: colorScheme.error), + }; + return AppSurface( + color: failed ? colorScheme.errorContainer : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar(child: Text(name.isEmpty ? '?' : name.characters.first)), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + if (userInfo.uid != null) + Text( + 'UID ${userInfo.uid}', + style: textTheme.labelSmall?.copyWith( + color: failed ? colorScheme.onErrorContainer : colorScheme.outline, + ), + ), + ], + ), + ), + sizedBoxW8H8, + stateIcon, + ], + ), + sizedBoxW8H8, + Text( + message, + style: textTheme.labelMedium?.copyWith( + color: failed ? colorScheme.onErrorContainer : colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } } diff --git a/lib/features/open_in_app/view/open_in_app_page.dart b/lib/features/open_in_app/view/open_in_app_page.dart index 7b1e0454c..d6420fb9f 100644 --- a/lib/features/open_in_app/view/open_in_app_page.dart +++ b/lib/features/open_in_app/view/open_in_app_page.dart @@ -2,7 +2,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/features/open_in_app/models/openable_forum_resource_model.dart'; @@ -11,7 +10,7 @@ import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/browser_launcher.dart'; import 'package:tsdm_client/utils/show_toast.dart'; -import 'package:tsdm_client/widgets/tips.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// A button opens route to [OpenInAppPage], class OpenInAppPageButton extends StatelessWidget { @@ -162,86 +161,100 @@ class _OpenInAppPageState extends State { @override Widget build(BuildContext context) { final tr = context.t.openInAppPage; + final colorScheme = Theme.of(context).colorScheme; + final isUrl = availableResources[currentResourceIndex] is UrlResource; return Scaffold( appBar: AppBar(title: Text(tr.title)), - body: ListView( - padding: context.safePadding().add(edgeInsetsL12R12), - children: [ - Wrap( - spacing: 4, - runSpacing: 4, - children: availableResources - .mapIndexed( - (idx, v) => FilterChip( - label: Text(v.typename(context)), - onSelected: (selected) => selected - ? setState(() { - currentResourceIndex = idx; - // A route recognized as another kind of resource is not what the input means now. - currentRoute = null; - }) - : null, - selected: currentResourceIndex == idx, + // One form surface centered like the other forms: kind of input, what it accepts, the field, the actions. + body: SafeArea( + top: false, + child: AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 24).add(context.safePadding()), + children: [ + AppFormSection( + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: availableResources + .mapIndexed( + (idx, v) => FilterChip( + label: Text(v.typename(context)), + onSelected: (selected) => selected + ? setState(() { + currentResourceIndex = idx; + // A route recognized as another kind of resource is not what the input means now. + currentRoute = null; + }) + : null, + selected: currentResourceIndex == idx, + ), + ) + .toList(), ), - ) - .toList(), - ), - sizedBoxW8H8, - Tips(availableResources[currentResourceIndex].detail(context), enablePadding: false), - sizedBoxW16H16, - Form( - key: formKey, - child: TextFormField( - controller: targetController, - autofocus: true, - keyboardType: TextInputType.url, - decoration: InputDecoration(labelText: availableResources[currentResourceIndex].typename(context)), - onChanged: (_) => currentRoute = null, - // Only [currentRoute] is updated here, it is not part of the build: no setState. - validator: (v) => availableResources[currentResourceIndex].validator()(context, v).match( - (e) { - currentRoute = null; - return e; - }, - (v) { - currentRoute = v; - return null; - }, + AppNoticeBanner(message: availableResources[currentResourceIndex].detail(context)), + Form( + key: formKey, + child: TextFormField( + controller: targetController, + autofocus: true, + keyboardType: TextInputType.url, + decoration: appFieldDecoration( + label: availableResources[currentResourceIndex].typename(context), + icon: isUrl ? Icons.link_outlined : Icons.tag, + ), + onChanged: (_) => currentRoute = null, + // Only [currentRoute] is updated here, it is not part of the build: no setState. + validator: (v) => availableResources[currentResourceIndex].validator()(context, v).match( + (e) { + currentRoute = null; + return e; + }, + (v) { + currentRoute = v; + return null; + }, + ), + ), + ), + FilledButton.icon( + label: Text(tr.open), + icon: const Icon(Icons.open_in_new), + onPressed: () async { + if (!formKey.currentState!.validate()) { + return; + } + if (currentRoute == null) { + return; + } + + // 手动点击也使用 pushReplacementNamed,保证路由栈干净 + context.pushReplacementNamed( + currentRoute!.screenPath, + pathParameters: currentRoute!.pathParameters, + queryParameters: currentRoute!.queryParameters, + ); + }, + ), + if (isUrl) ...[ + OutlinedButton.icon( + key: const ValueKey('open-in-app-browser'), + label: Text(tr.openInBrowser), + icon: const Icon(Icons.open_in_browser), + onPressed: _launchingBrowser ? null : _openInBrowser, + ), + Text( + tr.browserHint, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ], ), - ), - ), - sizedBoxW24H24, - FilledButton.icon( - label: Text(tr.open), - icon: const Icon(Icons.open_in_new), - onPressed: () async { - if (!formKey.currentState!.validate()) { - return; - } - if (currentRoute == null) { - return; - } - - // 手动点击也使用 pushReplacementNamed,保证路由栈干净 - context.pushReplacementNamed( - currentRoute!.screenPath, - pathParameters: currentRoute!.pathParameters, - queryParameters: currentRoute!.queryParameters, - ); - }, + ], ), - if (availableResources[currentResourceIndex] is UrlResource) ...[ - sizedBoxW8H8, - OutlinedButton.icon( - key: const ValueKey('open-in-app-browser'), - label: Text(tr.openInBrowser), - icon: const Icon(Icons.open_in_browser), - onPressed: _launchingBrowser ? null : _openInBrowser, - ), - sizedBoxW8H8, - Tips(tr.browserHint, enablePadding: false), - ], - ], + ), ), ); } diff --git a/lib/features/packet/view/packet_detail_page.dart b/lib/features/packet/view/packet_detail_page.dart index cb2c676e9..ab019c886 100644 --- a/lib/features/packet/view/packet_detail_page.dart +++ b/lib/features/packet/view/packet_detail_page.dart @@ -16,6 +16,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -50,50 +51,63 @@ class _PacketDetailPageState extends State { String _nextSortTip = ''; - Widget _buildInfoRow(BuildContext context, List data) { + /// Totals of the packet (time from first to last claim, claimers, coins); tapping shows them as a sentence. + Widget _buildSummary(BuildContext context, List data) { final tr = context.t.packetDetailPage; - final secondaryColor = Theme.of(context).colorScheme.secondary; - final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith(color: secondaryColor); + final colorScheme = Theme.of(context).colorScheme; final timeElapsed = data.first.time.difference(data.last.time).readable(context); final userCount = data.length; final coinsCount = data.fold(0, (prev, e) => prev + e.coins); - return InkWell( + Widget value(IconData icon, String text) => AppInsetBlock( + color: colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: colorScheme.secondary, size: 16), + sizedBoxW8H8, + Flexible( + child: Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: colorScheme.secondary), + ), + ), + ], + ), + ); + + return AppSurface( onTap: () async => showMessageSingleButtonDialog( context: context, title: tr.title, message: tr.statistics(users: userCount, coins: coinsCount, time: timeElapsed), ), - child: Column( + child: Row( children: [ - sizedBoxW4H4, - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon(Icons.timelapse_outlined, color: secondaryColor, size: 16), - sizedBoxW4H4, - Text(timeElapsed, style: textStyle), - sizedBoxW12H12, - Icon(Icons.person_outline, color: secondaryColor, size: 16), - sizedBoxW4H4, - Text('$userCount', style: textStyle), - sizedBoxW12H12, - Icon(FontAwesomeIcons.coins, color: secondaryColor, size: 16), - sizedBoxW4H4, - Text('$coinsCount', style: textStyle), - sizedBoxW12H12, - ], + const AppIconTile(Icons.redeem_outlined), + sizedBoxW12H12, + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + value(Icons.timelapse_outlined, timeElapsed), + value(Icons.person_outline, '$userCount'), + value(FontAwesomeIcons.coins, '$coinsCount'), + value(Icons.sort_outlined, _sortByCoins.tip(context)), + ], + ), ), - sizedBoxW4H4, ], ), ); } Widget _buildContent(BuildContext context, List data) { - final primaryColor = Theme.of(context).colorScheme.primary; - final secondaryColor = Theme.of(context).colorScheme.secondary; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; final dataSorted = switch (_sortByCoins) { _SortBy.time => data.toList(), @@ -101,44 +115,66 @@ class _PacketDetailPageState extends State { _SortBy.coinsMost => data.sortedByCompare((e) => e.coins, (lhs, rhs) => rhs - lhs).toList(), }; - return ListView.builder( - itemCount: data.length, - itemBuilder: (context, index) => InkWell( - onTap: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'username': dataSorted[index].username}), - child: Padding( - key: ValueKey(dataSorted[index].id), - padding: edgeInsetsL12T4R12.add(context.safePadding()), - child: Row( - children: [ - SizedBox( - width: 30, - child: Text( - '${dataSorted[index].id}', - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: secondaryColor), + return AppCenteredList( + builder: (context, side, _) => ListView.separated( + padding: side.copyWith(top: 12, bottom: 12).add(context.safePadding()), + itemCount: data.length + 1, + separatorBuilder: (_, _) => appListSeparator, + itemBuilder: (context, index) { + if (index == 0) return _buildSummary(context, data); + final item = dataSorted[index - 1]; + return AppSurface( + key: ValueKey(item.id), + padding: edgeInsetsL12T8R12B8, + onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'username': item.username}), + child: Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 32), + child: Text( + '${item.id}', + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith(color: colorScheme.secondary, fontWeight: FontWeight.bold), + ), ), - ), - Expanded( - child: ListTile( - leading: HeroUserAvatar(username: dataSorted[index].username, avatarUrl: null), - title: Text(dataSorted[index].username, style: TextStyle(color: primaryColor)), - subtitle: Text(dataSorted[index].time.yyyyMMDDHHMMSS()), - trailing: Row( + sizedBoxW8H8, + HeroUserAvatar(username: item.username, avatarUrl: null), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.username, + style: textTheme.titleSmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold), + ), + Text( + item.time.yyyyMMDDHHMMSS(), + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + sizedBoxW8H8, + AppInsetBlock( + color: colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - '${dataSorted[index].coins}', - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: secondaryColor), + '${item.coins}', + style: textTheme.labelLarge?.copyWith(color: colorScheme.onSecondaryContainer), ), sizedBoxW4H4, - const Icon(FontAwesomeIcons.coins, size: 12), + Icon(FontAwesomeIcons.coins, size: 12, color: colorScheme.onSecondaryContainer), ], ), ), - ), - ], - ), - ), + ], + ), + ); + }, ), ); } @@ -166,26 +202,16 @@ class _PacketDetailPageState extends State { builder: (context, state) { final tr = context.t.packetDetailPage; - final (body, infoRow) = switch (state) { - PacketDetailInitial() || PacketDetailLoading() => (const CenteredCircularIndicator(), null), - PacketDetailFailure() => ( - Center( - child: buildRetryButton(context, () async => context.read().fetchDetail(widget.tid)), - ), - null, + final body = switch (state) { + PacketDetailInitial() || PacketDetailLoading() => const CenteredCircularIndicator(), + PacketDetailFailure() => Center( + child: buildRetryButton(context, () async => context.read().fetchDetail(widget.tid)), ), - PacketDetailSuccess(:final data) when data.isEmpty => ( - Center( - child: Text( - context.t.general.noData, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ), - null, + PacketDetailSuccess(:final data) when data.isEmpty => AppStateView( + icon: Icons.redeem_outlined, + message: context.t.general.noData, ), - PacketDetailSuccess(:final data) => (_buildContent(context, data), _buildInfoRow(context, data)), + PacketDetailSuccess(:final data) => _buildContent(context, data), }; return Scaffold( @@ -203,7 +229,6 @@ class _PacketDetailPageState extends State { : null, ), ], - bottom: infoRow == null ? null : PreferredSize(preferredSize: const Size.fromHeight(24), child: infoRow), ), body: SafeArea(bottom: false, child: body), ); diff --git a/lib/features/points/views/points_page.dart b/lib/features/points/views/points_page.dart index 0daccd466..739facd4f 100644 --- a/lib/features/points/views/points_page.dart +++ b/lib/features/points/views/points_page.dart @@ -10,9 +10,8 @@ import 'package:tsdm_client/features/points/widgets/points_card.dart'; import 'package:tsdm_client/features/points/widgets/points_query_form.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/show_toast.dart'; -import 'package:tsdm_client/widgets/attr_block.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; -import 'package:tsdm_client/widgets/single_line_text.dart'; /// Page to show current logged user's points statistics and changelog. class PointsPage extends StatefulWidget { @@ -45,44 +44,79 @@ class _PointsPageState extends State with SingleTickerProviderStateM onRefresh: () { context.read().add(PointsStatisticsRefreshRequested()); }, - child: SingleChildScrollView( - controller: _statisticsScrollController, - child: Padding( - padding: edgeInsetsL12T4R12.add(context.safePadding()), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sizedBoxW4H4, - GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, mainAxisExtent: 70), - itemCount: attrList.length, - itemBuilder: (context, index) { - final attr = attrList[index]; - return AttrBlock(name: attr.key, value: attr.value); - }, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - ), - Row( - children: [ - SingleLineText( - context.t.pointsPage.statisticsTab.recentChangelog, - style: Theme.of(context).textTheme.titleMedium, + child: AppCenteredList( + builder: (context, side, width) { + // Value tiles size with the text instead of a fixed 70px grid cell, so large fonts are not clipped. + final scale = MediaQuery.textScalerOf(context).scale(1); + // Room inside the surface: page width minus the centering padding and the surface padding. + final contentWidth = width - side.horizontal - 32; + final columns = (contentWidth / (150 * scale)).floor().clamp(1, 4); + final rows = appRowCount(attrList.length, columns); + return SingleChildScrollView( + controller: _statisticsScrollController, + padding: side.copyWith(top: 12, bottom: 12).add(context.safePadding()), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (attrList.isEmpty) + AppStateView(icon: Icons.bar_chart_outlined, message: context.t.general.noData, scrollable: false) + else + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + context.t.pointsPage.statisticsTab.title, + icon: Icons.bar_chart_outlined, + padding: const EdgeInsets.only(bottom: 8), + ), + for (var row = 0; row < rows; row++) + Padding( + padding: EdgeInsets.only(top: row == 0 ? 0 : 8), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var column = 0; column < columns; column++) ...[ + if (column > 0) sizedBoxW8H8, + Expanded( + child: row * columns + column < attrList.length + ? _PointsValue( + name: attrList[row * columns + column].key, + value: attrList[row * columns + column].value, + ) + : sizedBoxEmpty, + ), + ], + ], + ), + ), + ), + ], + ), ), - const Spacer(), - TextButton( + sizedBoxW12H12, + AppSectionHeader( + context.t.pointsPage.statisticsTab.recentChangelog, + icon: Icons.history_outlined, + trailing: TextButton( child: Text(context.t.general.more), onPressed: () { _tabController.animateTo(1); }, ), - ], - ), - sizedBoxW12H12, - ...state.recentChangelog.map(PointsChangeCard.new).toList().cast().insertBetween(sizedBoxW4H4), - ], - ), - ), + ), + if (state.recentChangelog.isEmpty) + AppStateView(icon: Icons.history_outlined, message: context.t.general.noData, scrollable: false), + ...state.recentChangelog + .map(PointsChangeCard.new) + .toList() + .cast() + .insertBetween(appListSeparator), + ], + ), + ); + }, ), ); } @@ -108,12 +142,21 @@ class _PointsPageState extends State with SingleTickerProviderStateM onRefresh: () { context.read().add(PointsChangelogRefreshRequested()); }, - child: ListView.separated( - shrinkWrap: true, - padding: edgeInsetsL12T4R12.add(context.safePadding()), - itemCount: state.fullChangelog.length, - itemBuilder: (_, index) => PointsChangeCard(state.fullChangelog[index]), - separatorBuilder: (_, _) => sizedBoxW4H4, + child: AppCenteredList( + builder: (context, side, _) => state.fullChangelog.isEmpty + ? ListView( + padding: side.copyWith(top: 8, bottom: 12).add(context.safePadding()), + children: [ + AppStateView(icon: Icons.history_outlined, message: context.t.general.noData, scrollable: false), + ], + ) + : ListView.separated( + shrinkWrap: true, + padding: side.copyWith(top: 8, bottom: 12).add(context.safePadding()), + itemCount: state.fullChangelog.length, + itemBuilder: (_, index) => PointsChangeCard(state.fullChangelog[index]), + separatorBuilder: (_, _) => appListSeparator, + ), ), ); @@ -124,12 +167,23 @@ class _PointsPageState extends State with SingleTickerProviderStateM ..finishLoad() ..finishRefresh(); - return Column( - children: [ - Padding(padding: edgeInsetsL12T4R12, child: PointsQueryForm(state.allParameters)), - sizedBoxW4H4, - body, - ], + // The opened filter form scrolls on its own within 60% of the height (landscape phones, large fonts), so the + // records below always keep some room. + return LayoutBuilder( + builder: (context, constraints) => Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: constraints.maxHeight * 0.6), + child: AppCenteredList( + builder: (context, side, _) => SingleChildScrollView( + padding: side.copyWith(top: 12, bottom: 4), + child: PointsQueryForm(state.allParameters), + ), + ), + ), + body, + ], + ), ); } @@ -193,3 +247,31 @@ class _PointsPageState extends State with SingleTickerProviderStateM ); } } + +/// One kind of points: name above, the value in bold; both wrap with large fonts. +class _PointsValue extends StatelessWidget { + const _PointsValue({required this.name, required this.value}); + + final String name; + final String value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(name, style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + sizedBoxW4H4, + Text( + value, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.primary), + ), + ], + ), + ); + } +} diff --git a/lib/features/points/widgets/points_card.dart b/lib/features/points/widgets/points_card.dart index 5746d067f..987a6dc8b 100644 --- a/lib/features/points/widgets/points_card.dart +++ b/lib/features/points/widgets/points_card.dart @@ -5,6 +5,7 @@ import 'package:tsdm_client/extensions/date_time.dart'; import 'package:tsdm_client/features/points/models/models.dart'; import 'package:tsdm_client/utils/html/html_muncher.dart'; import 'package:tsdm_client/utils/html/munch_options.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:universal_html/parsing.dart'; /// A card to a change event of user's points. @@ -17,43 +18,73 @@ class PointsChangeCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: pointsChange.redirectUrl == null - ? null - : () async { - await context.dispatchAsUrl(pointsChange.redirectUrl!); - }, - child: Padding( - padding: edgeInsetsL12R12B12, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final (icon, tile, foreground) = switch (pointsChange.pointsChangeType) { + PointsChangeType.more => ( + Icons.trending_up_outlined, + colorScheme.primaryContainer, + colorScheme.onPrimaryContainer, + ), + PointsChangeType.less => ( + Icons.trending_down_outlined, + colorScheme.surfaceContainerHighest, + colorScheme.onSurfaceVariant, + ), + PointsChangeType.unlimited => ( + Icons.trending_flat_outlined, + colorScheme.secondaryContainer, + colorScheme.onSecondaryContainer, + ), + }; + return AppSurface( + onTap: pointsChange.redirectUrl == null + ? null + : () async { + await context.dispatchAsUrl(pointsChange.redirectUrl!); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - ListTile( - leading: switch (pointsChange.pointsChangeType) { - PointsChangeType.more => const Icon(Icons.trending_up_outlined, color: Color(0xF26C4F00)), - PointsChangeType.less => const Icon(Icons.trending_down_outlined, color: Color(0x99999999)), - PointsChangeType.unlimited => const Icon(Icons.trending_flat_outlined), - }, - title: Text(pointsChange.operation, style: TextStyle(color: Theme.of(context).colorScheme.secondary)), - subtitle: Text(pointsChange.time.yyyyMMDDHHMMSS()), - ), - munchElement( - context, - parseHtmlDocument(pointsChange.detail).body!, - options: const MunchOptions(renderUrl: false), - ), - sizedBoxW4H4, - Text( - pointsChange.changeMapString, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.primary), + AppIconTile(icon, color: tile, foregroundColor: foreground), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + pointsChange.operation, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.secondary), + ), + Text( + pointsChange.time.yyyyMMDDHHMMSS(), + style: textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), ), + if (pointsChange.redirectUrl != null) Icon(Icons.chevron_right, color: colorScheme.outline), ], ), - ), + sizedBoxW8H8, + munchElement( + context, + parseHtmlDocument(pointsChange.detail).body!, + options: const MunchOptions(renderUrl: false), + ), + sizedBoxW8H8, + AppInsetBlock( + color: colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Text( + pointsChange.changeMapString, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.w600), + ), + ), + ], ), ); } diff --git a/lib/features/points/widgets/points_query_form.dart b/lib/features/points/widgets/points_query_form.dart index f9de14db2..c0d44c9d0 100644 --- a/lib/features/points/widgets/points_query_form.dart +++ b/lib/features/points/widgets/points_query_form.dart @@ -9,6 +9,7 @@ import 'package:tsdm_client/features/points/models/models.dart'; import 'package:tsdm_client/features/points/repository/model/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; /// Form to make the user points changelog query filter. @@ -151,70 +152,81 @@ final class _PointsQueryFormState extends State { ); } + final extType = _picker( + label: context.t.pointsPage.changelogTab.extType, + icon: Icons.monetization_on_outlined, + value: pointsType?.name ?? '', + onTap: () async => pickExtType(context), + ); + final change = _picker( + label: context.t.pointsPage.changelogTab.changeType, + icon: Icons.ssid_chart_outlined, + value: changeType?.name ?? '', + onTap: () async => pickChangeType(context), + ); return [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: GestureDetector( - onTap: () async => pickExtType(context), - child: InputDecorator( - decoration: InputDecoration( - labelText: context.t.pointsPage.changelogTab.extType, - prefixIcon: const Icon(Icons.monetization_on_outlined), - suffixIcon: const Icon(Icons.arrow_drop_down_outlined), - ), - child: Text(pointsType?.name ?? ''), + // Side by side while both labels fit, stacked on narrow screens or with large fonts. + LayoutBuilder( + builder: (context, constraints) => constraints.maxWidth >= 480 * MediaQuery.textScalerOf(context).scale(1) + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: extType), + sizedBoxW8H8, + Expanded(child: change), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [extType, sizedBoxW12H12, change], ), - ), - ), - sizedBoxW4H4, - Expanded( - child: GestureDetector( - onTap: () async => pickChangeType(context), - child: InputDecorator( - decoration: InputDecoration( - labelText: context.t.pointsPage.changelogTab.changeType, - prefixIcon: const Icon(Icons.ssid_chart_outlined), - suffixIcon: const Icon(Icons.arrow_drop_down_outlined), - ), - child: Text(changeType?.name ?? ''), - ), - ), - ), - ], ), - GestureDetector( + _picker( + label: context.t.pointsPage.changelogTab.operationType, + icon: Icons.select_all_outlined, + value: operationType?.name ?? '', onTap: () async => pickOperationType(context), - child: InputDecorator( - decoration: InputDecoration( - labelText: context.t.pointsPage.changelogTab.operationType, - prefixIcon: const Icon(Icons.select_all_outlined), - suffixIcon: const Icon(Icons.arrow_drop_down_outlined), - ), - child: Text(operationType?.name ?? ''), - ), ), - GestureDetector( + _picker( + label: context.t.pointsPage.changelogTab.dateRange, + icon: Icons.date_range_outlined, + value: '$startTime - $endTime', onTap: () async => pickDateRange(context), - child: InputDecorator( - decoration: InputDecoration( - labelText: context.t.pointsPage.changelogTab.dateRange, - prefixIcon: const Icon(Icons.date_range_outlined), - ), - child: Text('$startTime - $endTime'), - ), + dropdown: false, ), - Row( - children: [ - Expanded( - child: FilledButton(onPressed: queryCallback, child: Text(context.t.pointsPage.changelogTab.query)), - ), - ], + FilledButton.icon( + onPressed: queryCallback, + icon: const Icon(Icons.search), + label: Text(context.t.pointsPage.changelogTab.query), ), ]; } + /// A read-only field opening a picker; focusable and activated by Enter or Space too. + Widget _picker({ + required String label, + required IconData icon, + required String value, + required VoidCallback onTap, + bool dropdown = true, + }) => InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(appInnerRadius), + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + suffixIcon: dropdown ? const Icon(Icons.arrow_drop_down_outlined) : null, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(appInnerRadius), + borderSide: BorderSide.none, + ), + ), + child: Text(value), + ), + ); + @override void initState() { super.initState(); @@ -232,29 +244,42 @@ final class _PointsQueryFormState extends State { return BlocBuilder( builder: (context, state) { - return Column( - children: [ - Row( + return AppSurface( + padding: const EdgeInsets.fromLTRB(16, 4, 8, 4), + child: Padding( + padding: showQueryFilter ? const EdgeInsets.only(right: 8, bottom: 8) : EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(context.t.pointsPage.changelogTab.query, style: Theme.of(context).textTheme.titleMedium), - const Spacer(), - IconButton( - icon: showQueryFilter - ? const Icon(Icons.expand_less_outlined) - : const Icon(Icons.expand_more_outlined), - tooltip: showQueryFilter - ? context.t.pointsPage.changelogTab.hideFilterTip - : context.t.pointsPage.changelogTab.showFilterTip, - onPressed: () { - setState(() { - showQueryFilter = !showQueryFilter; - }); - }, + Row( + children: [ + Icon(Icons.filter_list, size: 20, color: Theme.of(context).colorScheme.primary), + sizedBoxW8H8, + Expanded( + child: Text( + context.t.pointsPage.changelogTab.query, + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + IconButton( + icon: showQueryFilter + ? const Icon(Icons.expand_less_outlined) + : const Icon(Icons.expand_more_outlined), + tooltip: showQueryFilter + ? context.t.pointsPage.changelogTab.hideFilterTip + : context.t.pointsPage.changelogTab.showFilterTip, + onPressed: () { + setState(() { + showQueryFilter = !showQueryFilter; + }); + }, + ), + ], ), - ], + if (showQueryFilter) ..._buildContent(context, state), + ].insertBetween(sizedBoxW12H12), ), - if (showQueryFilter) ..._buildContent(context, state), - ].insertBetween(sizedBoxW12H12), + ), ); }, ); diff --git a/lib/features/post/view/fast_reply_edit_template_page.dart b/lib/features/post/view/fast_reply_edit_template_page.dart index 02fba7e7d..6a195e712 100644 --- a/lib/features/post/view/fast_reply_edit_template_page.dart +++ b/lib/features/post/view/fast_reply_edit_template_page.dart @@ -7,6 +7,7 @@ import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/bbcode_editor_controller.dart'; import 'package:tsdm_client/extensions/fp.dart'; +import 'package:tsdm_client/features/editor/widgets/editor_frame.dart'; import 'package:tsdm_client/features/editor/widgets/rich_editor.dart'; import 'package:tsdm_client/features/editor/widgets/toolbar.dart'; import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; @@ -18,6 +19,7 @@ import 'package:tsdm_client/utils/bbcode/spoiler_normalizer.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/platform.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Type of editing the fast reply template. @@ -125,41 +127,30 @@ class _FastReplyTemplateEditPageState extends State w ); } - /// Build the row to control a - Widget _buildControlRow(BuildContext context) { - return Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - sizedBoxW4H4, - // Only control expand or collapse on mobile platforms. - // For desktop, always expand the toolbar. - if (isMobile) - IconButton( - icon: const Icon(Icons.expand), - tooltip: context.t.bbcodeEditor.toolbar, - selectedIcon: Icon(Icons.expand_outlined, color: Theme.of(context).primaryColor), - isSelected: fullScreen, - onPressed: () { - setState(() { - fullScreen = !fullScreen; - }); - if (fullScreen) { - panelController.updatePanelType(ChatBottomPanelType.other, data: _BottomPanelType.toolbar); - } else { - panelController.updatePanelType(ChatBottomPanelType.keyboard); - } - }, - ), - sizedBoxW4H4, - ], - ), + /// Build the row of editor controls under the writing area. + Widget _buildControlRow(BuildContext context, EdgeInsets horizontalPadding) { + return EditorControlBar( + padding: horizontalPadding.add(edgeInsetsT4B4), + leading: [ + // Only control expand or collapse on mobile platforms. + // For desktop, always expand the toolbar. + if (isMobile) + IconButton( + icon: const Icon(Icons.expand), + tooltip: context.t.bbcodeEditor.toolbar, + selectedIcon: Icon(Icons.expand_outlined, color: Theme.of(context).primaryColor), + isSelected: fullScreen, + onPressed: () { + setState(() { + fullScreen = !fullScreen; + }); + if (fullScreen) { + panelController.updatePanelType(ChatBottomPanelType.other, data: _BottomPanelType.toolbar); + } else { + panelController.updatePanelType(ChatBottomPanelType.keyboard); + } + }, ), - ), ], ); } @@ -174,7 +165,12 @@ class _FastReplyTemplateEditPageState extends State w initialDelta: parseBBCodeTextToDelta(widget.initialValue?.data ?? '\n'), ); } else { - dataController = buildBBCodeEditorController(initialText: switch (widget.initialValue?.data) { final d? => normalizeBlockMarkerNesting(d), null => null }); + dataController = buildBBCodeEditorController( + initialText: switch (widget.initialValue?.data) { + final d? => normalizeBlockMarkerNesting(d), + null => null, + }, + ); } focusNode = FocusNode(); fullScreen = isDesktop; @@ -197,7 +193,7 @@ class _FastReplyTemplateEditPageState extends State w builder: (context, snapshot) { if (snapshot.hasError) { error('failed to load all fast reply templates: ${snapshot.error}'); - return Center(child: Text(context.t.general.failedToLoad)); + return AppStateView(error: true, icon: Icons.error_outline, message: context.t.general.failedToLoad); } if (!snapshot.hasData) { @@ -207,78 +203,79 @@ class _FastReplyTemplateEditPageState extends State w final result = snapshot.data!; if (result.isLeft()) { error('failed to unpack fast reply all templates result: ${result.unwrapErr()}'); - return Center(child: Text(context.t.general.failedToLoad)); + return AppStateView(error: true, icon: Icons.error_outline, message: context.t.general.failedToLoad); } final allTemplates = result.unwrap(); return Form( key: _formKey, - child: Column( - children: [ - if (widget.editType == FastReplyTemplateEditType.create) - Align( - child: SwitchListTile( - title: Text(tr.editPageOverride), - value: allowOverride, - onChanged: (v) => setState(() => allowOverride = v), - ), - ), - // Template name. - Padding( - padding: edgeInsetsL8R8.add(edgeInsetsT8), - child: TextFormField( - controller: nameController, - decoration: InputDecoration(labelText: tr.name), - validator: (v) { - if (v == null || v.isEmpty) { - return tr.editPageNameNotEmpty; - } + child: LayoutBuilder( + builder: (context, constraints) { + final horizontal = appCenteredPadding(constraints.maxWidth, maxWidth: appReadingMaxWidth); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Template name. + Padding( + padding: horizontal.add(edgeInsetsT12), + child: TextFormField( + controller: nameController, + decoration: InputDecoration(labelText: tr.name), + validator: (v) { + if (v == null || v.isEmpty) { + return tr.editPageNameNotEmpty; + } - // Duplicate check. - if (widget.editType == FastReplyTemplateEditType.create && !allowOverride) { - // Uid equality is ignored here. - if (allTemplates.any((e) => e.name == v)) { - return tr.editPageAlreadyExists; - } - } + // Duplicate check. + if (widget.editType == FastReplyTemplateEditType.create && !allowOverride) { + // Uid equality is ignored here. + if (allTemplates.any((e) => e.name == v)) { + return tr.editPageAlreadyExists; + } + } - return null; - }, - ), - ), - sizedBoxW4H4, - Expanded( - child: Padding( - padding: isMobile ? edgeInsetsL16R16 : edgeInsetsL4R4, - child: RichEditor(autoFocus: true, controller: dataController, editorFocusNode: focusNode), - ), - ), - if (isDesktop) - // Expand and can not replace with Align. - Row( - children: [ - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Padding( - padding: edgeInsetsL4R4.add(edgeInsetsT4), - child: EditorToolbar( - bbcodeController: dataController, - disabledFeatures: disabledFeatures, - editorFocusNode: focusNode, - ), + return null; + }, + ), + ), + // Only offered while drafting a new template: overriding one with the same name. + if (widget.editType == FastReplyTemplateEditType.create) + Padding( + padding: horizontal, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(tr.editPageOverride), + value: allowOverride, + onChanged: (v) => setState(() => allowOverride = v), + ), + ), + Expanded( + child: Padding( + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)), + child: EditorFrame( + focusNode: focusNode, + child: RichEditor(autoFocus: true, controller: dataController, editorFocusNode: focusNode), + ), + ), + ), + if (isDesktop) + ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Padding( + padding: horizontal.add(edgeInsetsT4), + child: EditorToolbar( + bbcodeController: dataController, + disabledFeatures: disabledFeatures, + editorFocusNode: focusNode, ), ), ), - ], - ), - ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Padding(padding: edgeInsetsR4.add(edgeInsetsB4), child: _buildControlRow(context)), - ), - if (isMobile) _buildMobileToolbar(context), - ], + _buildControlRow(context, horizontal), + if (isMobile) _buildMobileToolbar(context), + ], + ); + }, ), ); }, diff --git a/lib/features/post/view/fast_reply_template_page.dart b/lib/features/post/view/fast_reply_template_page.dart index 8c02844c2..ef6275bde 100644 --- a/lib/features/post/view/fast_reply_template_page.dart +++ b/lib/features/post/view/fast_reply_template_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/post/view/fast_reply_edit_template_page.dart'; import 'package:tsdm_client/features/post/widgets/fast_reply_template_card.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; @@ -10,6 +11,7 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page to view all templates for fast reply. @@ -35,7 +37,7 @@ class _FastReplyTemplatePageState extends State with Logg builder: (context, snapshot) { if (snapshot.hasError) { error('failed to load fast reply templates: ${snapshot.error!}'); - return Center(child: Text(context.t.general.failedToLoad)); + return AppStateView(error: true, icon: Icons.error_outline, message: context.t.general.failedToLoad); } if (!snapshot.hasData) { @@ -45,33 +47,33 @@ class _FastReplyTemplatePageState extends State with Logg final allTemplates = snapshot.data!; if (allTemplates.isEmpty) { - return Center( - child: Text( - tr.empty, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ); + return AppStateView(icon: Icons.quickreply_outlined, message: tr.empty); } - return SingleChildScrollView( - child: Padding( - padding: edgeInsetsL12T8R12, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: allTemplates - .map( - (e) => FastReplyTemplateCard( - key: ValueKey('FastReplyTemplateCard_${e.hashCode}'), - replyTemplate: e, - allowEdit: !widget.pick, - onTap: widget.pick ? .popBackSelf : .openMenu, - onLongPressOrRightClick: .openMenu, - ), - ) - .toList(), - ), - ), + return AppCenteredList( + builder: (context, horizontal, width) { + final columns = appColumnsFor(width); + return ListView.separated( + padding: horizontal.add(edgeInsetsT12).add(context.safePadding()), + itemCount: appRowCount(allTemplates.length, columns), + separatorBuilder: (_, _) => appListSeparator, + itemBuilder: (context, row) => AppColumnsRow( + row: row, + columns: columns, + count: allTemplates.length, + itemBuilder: (context, index) { + final e = allTemplates[index]; + return FastReplyTemplateCard( + key: ValueKey('FastReplyTemplateCard_${e.hashCode}'), + replyTemplate: e, + allowEdit: !widget.pick, + onTap: widget.pick ? .popBackSelf : .openMenu, + onLongPressOrRightClick: .openMenu, + ); + }, + ), + ); + }, ); }, ); diff --git a/lib/features/post/view/poll_create_page.dart b/lib/features/post/view/poll_create_page.dart index 2f59902cd..5d684bb9f 100644 --- a/lib/features/post/view/poll_create_page.dart +++ b/lib/features/post/view/poll_create_page.dart @@ -19,6 +19,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/browser_launcher.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Upstream JS starts the website form with three choice rows. @@ -32,9 +33,7 @@ class PollCreateInvalidPage extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(context.t.pollCreate.title)), - body: Center( - child: Padding(padding: const EdgeInsets.all(24), child: Text(context.t.pollCreate.state.unsupported)), - ), + body: AppStateView(icon: Icons.web_outlined, message: context.t.pollCreate.state.unsupported), ); } @@ -269,27 +268,48 @@ class _PollCreatePageState extends State { return issue == null ? null : _issueText(context, issue, form); } - Widget _constrained(Widget child) => Align( - alignment: Alignment.topCenter, - child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 720), child: child), + /// Centered form list: full width scroll view (scrollbar, drag anywhere), rows at most the form width. + Widget _formList({required Key key, required List children}) => AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, horizontal, width) => ListView( + key: key, + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)).add(context.safePadding()), + children: children, + ), ); - Widget _buildMessage(BuildContext context, String message, List actions) => Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 560), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SelectableText(message, textAlign: TextAlign.center), - sizedBoxW16H16, - Wrap(alignment: WrapAlignment.center, spacing: 8, runSpacing: 8, children: actions), - ], + Widget _buildMessage( + BuildContext context, + String message, + List actions, { + IconData icon = Icons.info_outline, + bool error = false, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppIconTile( + icon, + size: 64, + color: error ? colorScheme.errorContainer : colorScheme.secondaryContainer, + foregroundColor: error ? colorScheme.onErrorContainer : colorScheme.onSecondaryContainer, + ), + sizedBoxW16H16, + SelectableText(message, textAlign: TextAlign.center), + sizedBoxW16H16, + Wrap(alignment: WrapAlignment.center, spacing: 8, runSpacing: 8, children: actions), + ], + ), ), ), - ), - ); + ); + } Widget _browserButton(BuildContext context) => TextButton.icon( icon: const Icon(Icons.open_in_browser_outlined), @@ -303,25 +323,16 @@ class _PollCreatePageState extends State { onPressed: () async => _cubit.load(), ); - Widget _banner(BuildContext context, String title, String message, List actions) { - final theme = Theme.of(context); - return Card( - color: theme.colorScheme.errorContainer, - margin: const EdgeInsets.only(bottom: 12), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer)), - sizedBoxW4H4, - SelectableText(message, style: TextStyle(color: theme.colorScheme.onErrorContainer)), - Wrap(spacing: 8, children: actions), - ], - ), - ), - ); - } + Widget _banner(BuildContext context, String title, String message, List actions) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: AppNoticeBanner( + tone: AppNoticeTone.error, + title: title, + message: message, + selectable: true, + actions: actions, + ), + ); /// Nothing was posted: the fresh form check stopped before sending. Widget _preflightBanner(BuildContext context, PollCreateState state) { @@ -354,207 +365,270 @@ class _PollCreatePageState extends State { final offeredExtras = {for (final option in form.extraOptions) option.name}; final shownExtras = _extras.values.where((e) => offeredExtras.contains(e.name) || e.checked).toList(); - return _constrained( - ListView( - key: const ValueKey('poll-editor'), - padding: edgeInsetsL16R16.add(const EdgeInsets.symmetric(vertical: 12)).add(context.safePadding()), - children: [ - if (state.status == PollCreateStatus.rejected) - _banner( - context, - tr.result.rejectedTitle, - (state.message?.isNotEmpty ?? false) ? state.message! : tr.result.rejectedFallback, - [_reloadButton(context), _browserButton(context)], + return _formList( + key: const ValueKey('poll-editor'), + children: [ + if (state.status == PollCreateStatus.rejected) + _banner( + context, + tr.result.rejectedTitle, + (state.message?.isNotEmpty ?? false) ? state.message! : tr.result.rejectedFallback, + [_reloadButton(context), _browserButton(context)], + ), + if (state.status == PollCreateStatus.preflightFailed) _preflightBanner(context, state), + // Where the poll goes and what it is about. + AppFormSection( + children: [ + Row( + children: [ + Icon(Icons.forum_outlined, size: 18, color: theme.colorScheme.primary), + sizedBoxW8H8, + Expanded( + child: Text( + tr.forum(forum: form.forumName ?? '#${form.fid}'), + style: theme.textTheme.labelLarge?.copyWith(color: theme.colorScheme.primary), + ), + ), + ], ), - if (state.status == PollCreateStatus.preflightFailed) _preflightBanner(context, state), - Text(tr.forum(forum: form.forumName ?? '#${form.fid}'), style: theme.textTheme.labelLarge), - sizedBoxW12H12, - // Optional for polls: upstream exempts special threads from a forum's required type. - if (types.isNotEmpty || _threadType != null) ...[ - InputDecorator( - decoration: InputDecoration( - labelText: tr.threadType, - helperText: tr.threadTypeOptional, - helperMaxLines: 2, - errorMaxLines: 3, - errorText: _fieldError(context, form, const [PollInputIssue.threadTypeUnavailable]), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: typeOffered ? _threadType!.typeID : null, - isDense: true, - isExpanded: true, - items: [ - DropdownMenuItem(child: Text(tr.threadTypeNone)), - for (final type in types) DropdownMenuItem(value: type.typeID, child: Text(type.name)), - ], - onChanged: (id) => setState(() => _threadType = types.where((e) => e.typeID == id).firstOrNull), + // Optional for polls: upstream exempts special threads from a forum's required type. + if (types.isNotEmpty || _threadType != null) + InputDecorator( + decoration: InputDecoration( + labelText: tr.threadType, + helperText: tr.threadTypeOptional, + helperMaxLines: 2, + errorMaxLines: 3, + errorText: _fieldError(context, form, const [PollInputIssue.threadTypeUnavailable]), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: typeOffered ? _threadType!.typeID : null, + isDense: true, + isExpanded: true, + items: [ + DropdownMenuItem(child: Text(tr.threadTypeNone)), + for (final type in types) DropdownMenuItem(value: type.typeID, child: Text(type.name)), + ], + onChanged: (id) => setState(() => _threadType = types.where((e) => e.typeID == id).firstOrNull), + ), ), ), - ), - sizedBoxW12H12, - ], - TextField( - controller: _subject, - decoration: InputDecoration( - labelText: tr.subject, - suffixText: tr.subjectCounter( - used: discuzStrLen(discuzEscape(_subject.text.trim())), - limit: form.subjectLimit, + TextField( + controller: _subject, + decoration: InputDecoration( + labelText: tr.subject, + suffixText: tr.subjectCounter( + used: discuzStrLen(discuzEscape(_subject.text.trim())), + limit: form.subjectLimit, + ), + errorText: _fieldError(context, form, const [ + PollInputIssue.subjectEmpty, + PollInputIssue.subjectTooLong, + ]), ), - errorText: _fieldError(context, form, const [ - PollInputIssue.subjectEmpty, - PollInputIssue.subjectTooLong, - ]), + onChanged: (_) => setState(() {}), ), - onChanged: (_) => setState(() {}), - ), - sizedBoxW12H12, - TextField( - controller: _body, - minLines: 3, - maxLines: 10, - keyboardType: TextInputType.multiline, - decoration: InputDecoration(labelText: tr.body, hintText: tr.bodyHint, alignLabelWithHint: true), - ), - sizedBoxW16H16, - Text(tr.options, style: theme.textTheme.titleSmall), - Text( - tr.optionCount(count: filled, max: form.maxOptions), - style: theme.textTheme.bodySmall?.copyWith( - color: optionListError == null ? theme.colorScheme.outline : theme.colorScheme.error, + TextField( + controller: _body, + minLines: 3, + maxLines: 10, + keyboardType: TextInputType.multiline, + decoration: InputDecoration(labelText: tr.body, hintText: tr.bodyHint, alignLabelWithHint: true), ), - ), - if (optionListError != null) - Text(optionListError, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error)), - for (var i = 0; i < _options.length; i++) - Padding( - key: ObjectKey(_options[i]), - padding: edgeInsetsT8, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TextField( - controller: _options[i], - decoration: InputDecoration( - labelText: tr.optionLabel(index: i + 1), - errorText: _validation?.optionIssues[i] == null - ? null - : _issueText(context, _validation!.optionIssues[i]!, form), - errorMaxLines: 4, + ], + ), + appListSeparator, + // Choices: count and list problems in the header, one row per choice. + AppFormSection( + gap: 0, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.poll_outlined, size: 20, color: theme.colorScheme.primary), + sizedBoxW8H8, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr.options, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text( + tr.optionCount(count: filled, max: form.maxOptions), + style: theme.textTheme.bodySmall?.copyWith( + color: optionListError == null ? theme.colorScheme.outline : theme.colorScheme.error, + ), ), - onChanged: (_) => setState(() {}), - ), + if (optionListError != null) + Text( + optionListError, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error), + ), + ], ), - IconButton( - tooltip: tr.removeOption, - icon: const Icon(Icons.remove_circle_outline), - onPressed: _options.length <= 2 - ? null - : () => setState(() { - _disposeLater(_options.removeAt(i)); - _validation = null; - }), - ), - ], - ), - ), - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - icon: const Icon(Icons.add), - label: Text(tr.addOption), - onPressed: _options.length >= form.maxOptions - ? null - : () => setState(() => _options.add(TextEditingController())), + ), + ], ), - ), - sizedBoxW8H8, - Wrap( - spacing: 16, - runSpacing: 12, - children: [ - SizedBox( - width: 280, - child: TextField( - controller: _maxChoices, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration( - labelText: tr.maxChoices, - helperText: tr.maxChoicesHelper, - helperMaxLines: 3, - errorMaxLines: 3, - errorText: _fieldError(context, form, const [PollInputIssue.maxChoicesInvalid]), - ), + for (var i = 0; i < _options.length; i++) + Padding( + key: ObjectKey(_options[i]), + padding: edgeInsetsT8, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _options[i], + decoration: InputDecoration( + labelText: tr.optionLabel(index: i + 1), + errorText: _validation?.optionIssues[i] == null + ? null + : _issueText(context, _validation!.optionIssues[i]!, form), + errorMaxLines: 4, + ), + onChanged: (_) => setState(() {}), + ), + ), + IconButton( + tooltip: tr.removeOption, + icon: const Icon(Icons.remove_circle_outline), + onPressed: _options.length <= 2 + ? null + : () => setState(() { + _disposeLater(_options.removeAt(i)); + _validation = null; + }), + ), + ], ), ), - SizedBox( - width: 280, - child: TextField( - controller: _expiry, - keyboardType: TextInputType.number, - decoration: InputDecoration( - labelText: tr.expiry, - helperText: tr.expiryHelper, - helperMaxLines: 3, - errorMaxLines: 3, - errorText: _fieldError(context, form, const [PollInputIssue.expiryInvalid]), - ), - ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.add), + label: Text(tr.addOption), + onPressed: _options.length >= form.maxOptions + ? null + : () => setState(() => _options.add(TextEditingController())), ), - ], - ), - sizedBoxW8H8, - // A flag the form stopped offering stays visible while on, so the user decides instead of it vanishing. - if (form.visibility != null || _visibleAfterVote) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(tr.visibleAfterVote), - subtitle: form.visibility == null && flagError != null - ? Text(flagError, style: TextStyle(color: theme.colorScheme.error)) - : null, - value: _visibleAfterVote, - onChanged: (value) => setState(() => _visibleAfterVote = value), ), - if (form.overt != null || _publicVoters) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(tr.publicVoters), - subtitle: form.overt == null && flagError != null - ? Text(flagError, style: TextStyle(color: theme.colorScheme.error)) - : null, - value: _publicVoters, - onChanged: (value) => setState(() => _publicVoters = value), + ], + ), + appListSeparator, + // Rules of the vote. + AppFormSection( + gap: 4, + children: [ + LayoutBuilder( + builder: (context, constraints) { + // Side by side when both fit, otherwise one per row at the full width of the section. + final fieldWidth = constraints.maxWidth >= 2 * 240 + 16 + ? (constraints.maxWidth - 16) / 2 + : constraints.maxWidth; + return Wrap( + spacing: 16, + runSpacing: 12, + children: [ + SizedBox( + width: fieldWidth, + child: TextField( + controller: _maxChoices, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: tr.maxChoices, + helperText: tr.maxChoicesHelper, + helperMaxLines: 3, + errorMaxLines: 3, + errorText: _fieldError(context, form, const [PollInputIssue.maxChoicesInvalid]), + ), + ), + ), + SizedBox( + width: fieldWidth, + child: TextField( + controller: _expiry, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: tr.expiry, + helperText: tr.expiryHelper, + helperMaxLines: 3, + errorMaxLines: 3, + errorText: _fieldError(context, form, const [PollInputIssue.expiryInvalid]), + ), + ), + ), + ], + ); + }, ), - if (shownExtras.isNotEmpty) ...[ - sizedBoxW8H8, - Text(tr.additionalOptions, style: theme.textTheme.titleSmall), - for (final option in shownExtras) + // A flag the form stopped offering stays visible while on, so the user decides instead of it vanishing. + if (form.visibility != null || _visibleAfterVote) SwitchListTile( contentPadding: EdgeInsets.zero, - title: Text(option.readableName), - subtitle: offeredExtras.contains(option.name) - ? null - : Text(tr.issues.extraOptionUnavailable, style: TextStyle(color: theme.colorScheme.error)), - value: option.checked, - onChanged: option.disabled - ? null - : (value) => setState(() => _extras[option.name] = option.copyWith(checked: value)), + title: Text(tr.visibleAfterVote), + subtitle: form.visibility == null && flagError != null + ? Text(flagError, style: TextStyle(color: theme.colorScheme.error)) + : null, + value: _visibleAfterVote, + onChanged: (value) => setState(() => _visibleAfterVote = value), + ), + if (form.overt != null || _publicVoters) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(tr.publicVoters), + subtitle: form.overt == null && flagError != null + ? Text(flagError, style: TextStyle(color: theme.colorScheme.error)) + : null, + value: _publicVoters, + onChanged: (value) => setState(() => _publicVoters = value), ), ], - sizedBoxW12H12, - Text(tr.noDraft, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline)), - Align(alignment: Alignment.centerLeft, child: _browserButton(context)), - sizedBoxW12H12, - FilledButton.icon( - icon: const Icon(Icons.fact_check_outlined), - label: Text(tr.review), - onPressed: () => _review(context), + ), + if (shownExtras.isNotEmpty) ...[ + appListSeparator, + AppFormSection( + title: tr.additionalOptions, + icon: Icons.tune_outlined, + gap: 0, + children: [ + for (final option in shownExtras) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(option.readableName), + subtitle: offeredExtras.contains(option.name) + ? null + : Text(tr.issues.extraOptionUnavailable, style: TextStyle(color: theme.colorScheme.error)), + value: option.checked, + onChanged: option.disabled + ? null + : (value) => setState(() => _extras[option.name] = option.copyWith(checked: value)), + ), + ], ), ], - ), + sizedBoxW16H16, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, size: 16, color: theme.colorScheme.outline), + sizedBoxW8H8, + Expanded( + child: Text( + tr.noDraft, + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), + ), + ), + ], + ), + Align(alignment: Alignment.centerLeft, child: _browserButton(context)), + sizedBoxW12H12, + FilledButton.icon( + icon: const Icon(Icons.fact_check_outlined), + label: Text(tr.review), + onPressed: () => _review(context), + ), + ], ); } @@ -570,66 +644,82 @@ class _PollCreatePageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.outline)), + sizedBoxW2H2, value, ], ), ); - return _constrained( - ListView( - key: const ValueKey('poll-review'), - padding: edgeInsetsL16R16.add(const EdgeInsets.symmetric(vertical: 12)).add(context.safePadding()), - children: [ - Text(tr.title, style: theme.textTheme.titleLarge), - row(tr.forum, Text(form.forumName ?? '#${form.fid}')), - if (poll.threadType != null) row(tr.threadType, Text(poll.threadType!.name)), - row(tr.subject, SelectableText(poll.subject)), - row( - tr.body, - poll.message.trim().isEmpty - ? Text(tr.bodyEmpty) - : Text(poll.message, maxLines: 6, overflow: TextOverflow.ellipsis), - ), - row( - tr.options(count: poll.options.length), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [for (var i = 0; i < poll.options.length; i++) SelectableText('${i + 1}. ${poll.options[i]}')], - ), - ), - row(tr.choices, Text(poll.maxChoices == 1 ? tr.single : tr.multiple(count: poll.maxChoices))), - row(tr.expiry, Text(poll.expiryDays == 0 ? tr.unlimited : tr.days(days: poll.expiryDays))), - if (form.visibility != null) row(tr.visibleAfterVote, Text(poll.visibleAfterVote ? tr.yes : tr.no)), - if (form.overt != null) row(tr.publicVoters, Text(poll.publicVoters ? tr.yes : tr.no)), - if (poll.extraOptions.isNotEmpty) - row(tr.additionalOptions, Text(poll.extraOptions.map((e) => e.readableName).join(', '))), - sizedBoxW16H16, - Text(tr.warning, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error)), - if (checking) ...[sizedBoxW8H8, Text(context.t.pollCreate.checking)], - if (submitting) ...[sizedBoxW8H8, Text(context.t.pollCreate.submitting)], - sizedBoxW16H16, - Wrap( - alignment: WrapAlignment.end, - spacing: 12, - runSpacing: 8, + return _formList( + key: const ValueKey('poll-review'), + children: [ + Row( + children: [ + const AppIconTile(Icons.fact_check_outlined), + sizedBoxW12H12, + Expanded(child: Text(tr.title, style: theme.textTheme.titleLarge)), + ], + ), + sizedBoxW12H12, + // Everything that will be sent, in one surface. + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Going back while checking cancels the check; nothing has been sent yet. - OutlinedButton(onPressed: submitting ? null : _cubit.cancelReview, child: Text(tr.back)), - FilledButton.icon( - icon: submitting || checking ? sizedCircularProgressIndicator : const Icon(Icons.send), - label: Text(tr.submit), - onPressed: _cubit.canConfirm ? () async => _cubit.confirm() : null, + row(tr.forum, Text(form.forumName ?? '#${form.fid}')), + if (poll.threadType != null) row(tr.threadType, Text(poll.threadType!.name)), + row(tr.subject, SelectableText(poll.subject)), + row( + tr.body, + poll.message.trim().isEmpty + ? Text(tr.bodyEmpty) + : Text(poll.message, maxLines: 6, overflow: TextOverflow.ellipsis), ), + row( + tr.options(count: poll.options.length), + AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < poll.options.length; i++) SelectableText('${i + 1}. ${poll.options[i]}'), + ], + ), + ), + ), + row(tr.choices, Text(poll.maxChoices == 1 ? tr.single : tr.multiple(count: poll.maxChoices))), + row(tr.expiry, Text(poll.expiryDays == 0 ? tr.unlimited : tr.days(days: poll.expiryDays))), + if (form.visibility != null) row(tr.visibleAfterVote, Text(poll.visibleAfterVote ? tr.yes : tr.no)), + if (form.overt != null) row(tr.publicVoters, Text(poll.publicVoters ? tr.yes : tr.no)), + if (poll.extraOptions.isNotEmpty) + row(tr.additionalOptions, Text(poll.extraOptions.map((e) => e.readableName).join(', '))), ], ), - ], - ), + ), + sizedBoxW12H12, + AppNoticeBanner(tone: AppNoticeTone.error, icon: Icons.warning_amber_outlined, message: tr.warning), + if (checking) ...[sizedBoxW8H8, Text(context.t.pollCreate.checking)], + if (submitting) ...[sizedBoxW8H8, Text(context.t.pollCreate.submitting)], + sizedBoxW16H16, + Wrap( + alignment: WrapAlignment.end, + spacing: 12, + runSpacing: 8, + children: [ + // Going back while checking cancels the check; nothing has been sent yet. + OutlinedButton(onPressed: submitting ? null : _cubit.cancelReview, child: Text(tr.back)), + FilledButton.icon( + icon: submitting || checking ? sizedCircularProgressIndicator : const Icon(Icons.send), + label: Text(tr.submit), + onPressed: _cubit.canConfirm ? () async => _cubit.confirm() : null, + ), + ], + ), + ], ); } Widget _buildResult(BuildContext context, PollCreateState state) { final tr = context.t.pollCreate.result; - final theme = Theme.of(context); final tid = state.tid; final (IconData icon, String title, String message) = switch (state.status) { PollCreateStatus.published => (Icons.check_circle_outline, tr.publishedTitle, tr.published), @@ -638,8 +728,7 @@ class _PollCreatePageState extends State { }; final unconfirmed = state.status == PollCreateStatus.unconfirmed; - return _buildMessage(context, '$title\n\n$message', [ - Icon(icon, color: unconfirmed ? theme.colorScheme.error : theme.colorScheme.primary), + return _buildMessage(context, '$title\n\n$message', icon: icon, error: unconfirmed, [ if (tid != null) FilledButton( onPressed: () => context.pushReplacementNamed(ScreenPaths.threadV1, queryParameters: {'tid': tid}), @@ -665,23 +754,34 @@ class _PollCreatePageState extends State { final form = state.form; return switch (state.status) { PollCreateStatus.loading => const CenteredCircularIndicator(), - PollCreateStatus.loadFailed => _buildMessage(context, tr.state.loadFailed, [ - _reloadButton(context), - _browserButton(context), - ]), - PollCreateStatus.loginRequired => _buildMessage(context, tr.state.loginRequired, [ + PollCreateStatus.loadFailed => _buildMessage( + context, + tr.state.loadFailed, + icon: Icons.error_outline, + error: true, + [ + _reloadButton(context), + _browserButton(context), + ], + ), + PollCreateStatus.loginRequired => _buildMessage(context, tr.state.loginRequired, icon: Icons.login_outlined, [ _reloadButton(context), _browserButton(context), ]), PollCreateStatus.denied => _buildMessage( context, (state.message?.isNotEmpty ?? false) ? state.message! : tr.state.denied, + icon: Icons.block_outlined, + error: true, [_browserButton(context)], ), - PollCreateStatus.unsupported => _buildMessage(context, tr.state.unsupported, [_browserButton(context)]), + PollCreateStatus.unsupported => _buildMessage(context, tr.state.unsupported, icon: Icons.web_outlined, [ + _browserButton(context), + ]), PollCreateStatus.identityChanged => _buildMessage( context, state.interruptedSubmit ? tr.state.accountChangedDuringSubmit : tr.state.accountChanged, + icon: Icons.manage_accounts_outlined, const [], ), PollCreateStatus.editing || diff --git a/lib/features/post/view/post_edit_page.dart b/lib/features/post/view/post_edit_page.dart index 41781a678..d87fa360b 100644 --- a/lib/features/post/view/post_edit_page.dart +++ b/lib/features/post/view/post_edit_page.dart @@ -13,9 +13,9 @@ import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/bbcode_editor_controller.dart'; import 'package:tsdm_client/extensions/build_context.dart'; -import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/editor/widgets/editor_frame.dart'; import 'package:tsdm_client/features/editor/widgets/rich_editor.dart'; import 'package:tsdm_client/features/editor/widgets/toolbar.dart'; import 'package:tsdm_client/features/post/bloc/post_edit_bloc.dart'; @@ -38,6 +38,7 @@ import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; @@ -523,230 +524,221 @@ class _PostEditPageState extends State with LoggerMixin { ); } + /// Thread type and title fields: side by side on wide windows, the type above the title on phones and with large + /// text, so neither field is squeezed. Widget _buildTitleRow(BuildContext context, PostEditState state) { final tr = context.t.postEditPage; - final ret = []; + Widget? typeField; if (state.content?.threadTypeList?.isNotEmpty ?? false) { - ret.add( - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 100), - child: TextFormField( - key: formKey, - controller: threadTypeController, - decoration: InputDecoration(labelText: tr.threadType, suffixIcon: const Icon(Icons.arrow_drop_down)), - readOnly: true, - onTap: () async => _showSelectThreadTypeBottomSheet(context, state), - // Only auto focus to title field when writing new thread. - validator: (v) { - if (widget.editType.isEditingPost) { - // Skip check when editing post, post have no thread type. - return null; - } + typeField = TextFormField( + key: formKey, + controller: threadTypeController, + decoration: InputDecoration(labelText: tr.threadType, suffixIcon: const Icon(Icons.arrow_drop_down)), + readOnly: true, + onTap: () async => _showSelectThreadTypeBottomSheet(context, state), + // Only auto focus to title field when writing new thread. + validator: (v) { + if (widget.editType.isEditingPost) { + // Skip check when editing post, post have no thread type. + return null; + } - // Thread type should not be null nor no more than zero. - if (v == null || - threadType == null || - threadType!.typeID == null || - ((threadType!.typeID?.parseToInt() ?? -1) <= 0)) { - return tr.threadTypeShouldNotBeEmpty; - } - return null; - }, - ), - ), + // Thread type should not be null nor no more than zero. + if (v == null || + threadType == null || + threadType!.typeID == null || + ((threadType!.typeID?.parseToInt() ?? -1) <= 0)) { + return tr.threadTypeShouldNotBeEmpty; + } + return null; + }, ); } // Always add title no matter thread type presents or not. // All thread floors have a legal "subject" here. - ret.add( - Expanded( - child: TextFormField( - controller: threadTitleController, - decoration: InputDecoration( - labelText: tr.title, - suffixText: ' $threadTitleRestLength', - suffixIcon: IconButton( - icon: const Icon(Icons.info_outline), - onPressed: () async => showMessageSingleButtonDialog( - context: context, - title: tr.whyTitleDialog.title, - message: tr.whyTitleDialog.detail, - ), - ), + final titleField = TextFormField( + controller: threadTitleController, + decoration: InputDecoration( + labelText: tr.title, + suffixText: ' $threadTitleRestLength', + suffixIcon: IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () async => showMessageSingleButtonDialog( + context: context, + title: tr.whyTitleDialog.title, + message: tr.whyTitleDialog.detail, ), - onChanged: (value) { - setState(() { - threadTitleRestLength = - (state.content?.threadTitleMaxLength ?? _defaultThreadTitleMaxlength) - value.parseUtf8Length; - }); - }, - validator: (v) { - if (widget.editType.isEditingPost) { - // Skip check when editing post, post have no thread type. - return null; - } - if (v == null) { - return tr.titleShouldNotBeEmpty; - } - final titleLength = v.parseUtf8Length; - if (titleLength <= 0) { - return tr.titleShouldNotBeEmpty; - } - if (titleLength >= (state.content?.threadTitleMaxLength ?? _defaultThreadTitleMaxlength)) { - return tr.titleTooLong; - } - return null; - }, ), ), + onChanged: (value) { + setState(() { + threadTitleRestLength = + (state.content?.threadTitleMaxLength ?? _defaultThreadTitleMaxlength) - value.parseUtf8Length; + }); + }, + validator: (v) { + if (widget.editType.isEditingPost) { + // Skip check when editing post, post have no thread type. + return null; + } + if (v == null) { + return tr.titleShouldNotBeEmpty; + } + final titleLength = v.parseUtf8Length; + if (titleLength <= 0) { + return tr.titleShouldNotBeEmpty; + } + if (titleLength >= (state.content?.threadTitleMaxLength ?? _defaultThreadTitleMaxlength)) { + return tr.titleTooLong; + } + return null; + }, ); - if (ret.isEmpty) { - return Container(); + if (typeField == null) { + return titleField; } - return Padding( - padding: edgeInsetsL8R8.add(edgeInsetsT8), - child: Row(children: ret.insertBetween(sizedBoxW24H24)), + final type = typeField; + return LayoutBuilder( + builder: (context, constraints) { + // Keep the title at least ~16 characters wide beside the type, otherwise stack them. + final stacked = constraints.maxWidth < MediaQuery.textScalerOf(context).scale(520); + if (stacked) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [type, sizedBoxW8H8, titleField], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 180, child: type), + sizedBoxW12H12, + Expanded(child: titleField), + ], + ); + }, ); } - /// Build the row to control a - Widget _buildControlRow(BuildContext context, PostEditState state) { - return Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - sizedBoxW4H4, - // Only control expand or collapse on mobile platforms. - // For desktop, always expand the toolbar. - if (isMobile) - IconButton( - icon: const Icon(Icons.expand), - tooltip: context.t.bbcodeEditor.toolbar, - selectedIcon: Icon(Icons.expand_outlined, color: Theme.of(context).primaryColor), - isSelected: fullScreen, - onPressed: () { - setState(() { - fullScreen = !fullScreen; - }); - if (fullScreen) { - panelController.updatePanelType(ChatBottomPanelType.other, data: _BottomPanelType.toolbar); - } else { - panelController.updatePanelType(ChatBottomPanelType.keyboard); - } - }, - ), - if (additionalOptionsMap != null) - IconButton( - icon: const Icon(Icons.settings_outlined), - tooltip: context.t.bbcodeEditor.additionalOptions, - onPressed: () async => _showAdditionalOptionBottomSheet(context, state), - ), - // Reply template - IconButton( - icon: const Icon(Icons.quickreply_outlined), - tooltip: context.t.fastReplyTemplate.title, - onPressed: () async { - final pickResult = await context.pushNamed( - ScreenPaths.fastReplyTemplate, - pathParameters: {'pick': 'true'}, - ); - - if (!context.mounted) { - return; - } - if (pickResult != null) { - bbcodeController.insertBBCode(normalizeBlockMarkerNesting(pickResult.data)); - } - focusNode.requestFocus(); - }, - ), - if (state.content?.permList?.isNotEmpty ?? false) - Badge( - label: Text(threadPerm?.perm ?? ''), - offset: const Offset(-4, 4), - isLabelVisible: threadPerm != null && threadPerm!.perm.isNotEmpty, - child: IconButton( - icon: const Icon(Icons.lock_open_outlined), - selectedIcon: const Icon(Icons.lock_outline), - isSelected: threadPerm != null && threadPerm!.perm.isNotEmpty, - tooltip: context.t.bbcodeEditor.readPerm, - onPressed: () async { - final selectedPerm = await showSelectPermDialog(context, state.content!.permList!, threadPerm); - if (selectedPerm == null || !context.mounted) { - return; - } - setState(() { - threadPerm = selectedPerm; - }); - }, - ), - ), - if (price != null) - Badge( - label: Text('$price'), - textStyle: Theme.of(context).textTheme.labelSmall, - offset: const Offset(-4, 4), - isLabelVisible: price != null && price != 0, - child: IconButton( - icon: const Icon(Icons.money_off_outlined), - tooltip: context.t.postEditPage.priceDialog.entryTooltip, - selectedIcon: const Icon(Icons.attach_money_outlined), - isSelected: price != null && price != 0, - onPressed: () async { - // TODO: show a dialog to set price. - final inputPrice = await showInputPriceDialog(context, price, state.content?.maxPrice); - if (inputPrice != null) { - setState(() { - price = inputPrice; - }); - } - }, - ), - ), - sizedBoxW4H4, - ], - ), + /// Build the row of editor controls (toolbar, options, templates, permission, price) under the writing area. + Widget _buildControlRow(BuildContext context, PostEditState state, EdgeInsets horizontalPadding) { + return EditorControlBar( + padding: horizontalPadding.add(edgeInsetsT4B4), + leading: [ + // Only control expand or collapse on mobile platforms. + // For desktop, always expand the toolbar. + if (isMobile) + IconButton( + icon: const Icon(Icons.expand), + tooltip: context.t.bbcodeEditor.toolbar, + selectedIcon: Icon(Icons.expand_outlined, color: Theme.of(context).primaryColor), + isSelected: fullScreen, + onPressed: () { + setState(() { + fullScreen = !fullScreen; + }); + if (fullScreen) { + panelController.updatePanelType(ChatBottomPanelType.other, data: _BottomPanelType.toolbar); + } else { + panelController.updatePanelType(ChatBottomPanelType.keyboard); + } + }, + ), + if (additionalOptionsMap != null) + IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: context.t.bbcodeEditor.additionalOptions, + onPressed: () async => _showAdditionalOptionBottomSheet(context, state), ), + // Reply template + IconButton( + icon: const Icon(Icons.quickreply_outlined), + tooltip: context.t.fastReplyTemplate.title, + onPressed: () async { + final pickResult = await context.pushNamed( + ScreenPaths.fastReplyTemplate, + pathParameters: {'pick': 'true'}, + ); + + if (!context.mounted) { + return; + } + if (pickResult != null) { + bbcodeController.insertBBCode(normalizeBlockMarkerNesting(pickResult.data)); + } + focusNode.requestFocus(); + }, ), + if (state.content?.permList?.isNotEmpty ?? false) + Badge( + label: Text(threadPerm?.perm ?? ''), + offset: const Offset(-4, 4), + isLabelVisible: threadPerm != null && threadPerm!.perm.isNotEmpty, + child: IconButton( + icon: const Icon(Icons.lock_open_outlined), + selectedIcon: const Icon(Icons.lock_outline), + isSelected: threadPerm != null && threadPerm!.perm.isNotEmpty, + tooltip: context.t.bbcodeEditor.readPerm, + onPressed: () async { + final selectedPerm = await showSelectPermDialog(context, state.content!.permList!, threadPerm); + if (selectedPerm == null || !context.mounted) { + return; + } + setState(() { + threadPerm = selectedPerm; + }); + }, + ), + ), + if (price != null) + Badge( + label: Text('$price'), + textStyle: Theme.of(context).textTheme.labelSmall, + offset: const Offset(-4, 4), + isLabelVisible: price != null && price != 0, + child: IconButton( + icon: const Icon(Icons.money_off_outlined), + tooltip: context.t.postEditPage.priceDialog.entryTooltip, + selectedIcon: const Icon(Icons.attach_money_outlined), + isSelected: price != null && price != 0, + onPressed: () async { + // TODO: show a dialog to set price. + final inputPrice = await showInputPriceDialog(context, price, state.content?.maxPrice); + if (inputPrice != null) { + setState(() { + price = inputPrice; + }); + } + }, + ), + ), ], ); } Widget _buildBody(BuildContext context, PostEditState state) { if (state.status == PostEditStatus.identityChanged) { - return Center( - child: Padding(padding: const EdgeInsets.all(24), child: Text(context.t.draftBox.accountChanged)), - ); + return AppStateView(icon: Icons.manage_accounts_outlined, message: context.t.draftBox.accountChanged); } if (state.status == PostEditStatus.unsupported) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(context.t.draftBox.unsupported), - TextButton( - onPressed: () async { - final url = widget.editType == PostEditType.newThread - ? '$baseUrl/forum.php?mod=post&action=newthread&fid=${widget.fid}' - : _formatDataUrl(fid: widget.fid, tid: widget.tid!, pid: widget.pid!); - try { - await openInExternalBrowser(Uri.parse(url)); - } on Object { - if (context.mounted) showSnackBar(context: context, message: context.t.general.failedToLoad); - } - }, - child: Text(context.t.draftBox.openBrowser), - ), - ], - ), + return AppStateView( + icon: Icons.web_outlined, + message: context.t.draftBox.unsupported, + action: TextButton.icon( + icon: const Icon(Icons.open_in_browser_outlined), + onPressed: () async { + final url = widget.editType == PostEditType.newThread + ? '$baseUrl/forum.php?mod=post&action=newthread&fid=${widget.fid}' + : _formatDataUrl(fid: widget.fid, tid: widget.tid!, pid: widget.pid!); + try { + await openInExternalBrowser(Uri.parse(url)); + } on Object { + if (context.mounted) showSnackBar(context: context, message: context.t.general.failedToLoad); + } + }, + label: Text(context.t.draftBox.openBrowser), ), ); } @@ -787,46 +779,45 @@ class _PostEditPageState extends State with LoggerMixin { initialized = true; } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTitleRow(context, state), - sizedBoxW4H4, - // Post data editor. - // Now we don't restrict the thread type and thread title, so it's better to focus on content area. - Expanded( - child: Padding( - padding: isMobile ? edgeInsetsL16R16 : edgeInsetsL4R4, - child: RichEditor(autoFocus: true, controller: bbcodeController, editorFocusNode: focusNode), - ), - ), - if (isDesktop) - // Expand and can not replace with Align. - Row( - children: [ - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Padding( - padding: edgeInsetsL4R4.add(edgeInsetsT4), - child: EditorToolbar( - bbcodeController: bbcodeController, - disabledFeatures: _disabledFeatures, - editorFocusNode: focusNode, - collectDocumentMetadata: collectThreadMetata, - applyDocumentMetadata: (metadata) => applyThreadMetadata(metadata, state), - ), + // Title fields and the writing area stay at a readable width on wide windows; the bars under them keep the full + // width of the page with their buttons aligned to the same column. + return LayoutBuilder( + builder: (context, constraints) { + final horizontal = appCenteredPadding(constraints.maxWidth, maxWidth: appReadingMaxWidth); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding(padding: horizontal.add(edgeInsetsT12), child: _buildTitleRow(context, state)), + // Post data editor. + // Now we don't restrict the thread type and thread title, so it's better to focus on content area. + Expanded( + child: Padding( + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)), + child: EditorFrame( + focusNode: focusNode, + child: RichEditor(autoFocus: true, controller: bbcodeController, editorFocusNode: focusNode), + ), + ), + ), + if (isDesktop) + ColoredBox( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Padding( + padding: horizontal.add(edgeInsetsT4), + child: EditorToolbar( + bbcodeController: bbcodeController, + disabledFeatures: _disabledFeatures, + editorFocusNode: focusNode, + collectDocumentMetadata: collectThreadMetata, + applyDocumentMetadata: (metadata) => applyThreadMetadata(metadata, state), ), ), ), - ], - ), - ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: Padding(padding: edgeInsetsR4.add(edgeInsetsB4), child: _buildControlRow(context, state)), - ), - if (isMobile) _buildMobileToolbar(context, state), - ], + _buildControlRow(context, state, horizontal), + if (isMobile) _buildMobileToolbar(context, state), + ], + ); + }, ); } diff --git a/lib/features/post/widgets/fast_reply_template_card.dart b/lib/features/post/widgets/fast_reply_template_card.dart index 083f72478..811fbec40 100644 --- a/lib/features/post/widgets/fast_reply_template_card.dart +++ b/lib/features/post/widgets/fast_reply_template_card.dart @@ -10,6 +10,7 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/widgets/adaptive_ink_response.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Actions in popup menu. enum _MenuAction { @@ -141,8 +142,10 @@ class _FastReplyTemplateCardState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; return Card( - clipBehavior: Clip.hardEdge, + clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, child: AdaptiveInkResponse( onTapUp: switch (widget.onTap) { @@ -156,15 +159,34 @@ class _FastReplyTemplateCardState extends State { .openMenu => (pos) async => openMenu(pos.globalPosition), }, child: Padding( - padding: edgeInsetsL12T12R12B12, + padding: edgeInsetsL16T12R16B12, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - replyTemplate.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary), + Row( + children: [ + const AppIconTile(Icons.quickreply_outlined, size: 32), + sizedBoxW12H12, + Expanded( + child: Text( + replyTemplate.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (widget.allowEdit) Icon(Icons.more_vert, size: 18, color: colorScheme.outline), + ], ), sizedBoxW8H8, - Text(maxLines: 3, replyTemplate.data.truncate(40, ellipsis: true)), + AppInsetBlock( + child: Text( + replyTemplate.data.truncate(120, ellipsis: true), + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), ], ), ), diff --git a/lib/features/post/widgets/input_price_dialog.dart b/lib/features/post/widgets/input_price_dialog.dart index b3791352e..c2acb1737 100644 --- a/lib/features/post/widgets/input_price_dialog.dart +++ b/lib/features/post/widgets/input_price_dialog.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// Show a dialog to let user input a price for current thread. @@ -53,13 +55,24 @@ class _InputPriceDialogState extends State<_InputPriceDialog> { Widget build(BuildContext context) { final tr = context.t.postEditPage.priceDialog; return CustomAlertDialog.sync( - title: Text(tr.title), + title: Row( + children: [ + const AppIconTile(Icons.attach_money_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.title)), + ], + ), content: Form( key: formKey, child: TextFormField( controller: priceController, autofocus: true, - decoration: InputDecoration(helperText: tr.maximum(maxPrice: widget.maxPrice ?? 65535)), + decoration: InputDecoration( + prefixIcon: const Icon(Icons.attach_money_outlined), + helperText: tr.maximum(maxPrice: widget.maxPrice ?? 65535), + helperMaxLines: 2, + errorMaxLines: 2, + ), keyboardType: const TextInputType.numberWithOptions(decimal: true), validator: (v) { if (v == null) { @@ -82,7 +95,7 @@ class _InputPriceDialogState extends State<_InputPriceDialog> { ), actions: [ TextButton(child: Text(context.t.general.cancel), onPressed: () => context.pop()), - TextButton( + FilledButton( child: Text(context.t.general.ok), onPressed: () { if (formKey.currentState?.validate() ?? false) { diff --git a/lib/features/post/widgets/select_perm_dialog.dart b/lib/features/post/widgets/select_perm_dialog.dart index 8c4f17a8d..bb5937e63 100644 --- a/lib/features/post/widgets/select_perm_dialog.dart +++ b/lib/features/post/widgets/select_perm_dialog.dart @@ -1,10 +1,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; @@ -50,8 +52,15 @@ class _SelectPermDialogState extends State<_SelectPermDialog> { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return CustomAlertDialog.sync( - title: Text(context.t.postEditPage.permDialog.title), + title: Row( + children: [ + const AppIconTile(Icons.lock_outline, size: 36), + sizedBoxW12H12, + Expanded(child: Text(context.t.postEditPage.permDialog.title)), + ], + ), content: Column( children: groupMap.keys .sorted( @@ -63,10 +72,25 @@ class _SelectPermDialogState extends State<_SelectPermDialog> { ) .map( (e) => SelectableListTile( - title: Text('$e', style: TextStyle(color: Theme.of(context).colorScheme.primary)), - subtitle: Text( + // The permission value in a fixed width tile, the user groups reaching it beside. + leading: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 44), + child: AppInsetBlock( + color: currentPerm?.perm == '$e' ? colorScheme.primary : colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + '$e', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + color: currentPerm?.perm == '$e' ? colorScheme.onPrimary : colorScheme.primary, + ), + ), + ), + ), + title: Text( groupMap[e]!.map((p) => p.groupName).join(' '), - style: Theme.of(context).textTheme.labelMedium, + style: Theme.of(context).textTheme.bodyMedium, ), selected: currentPerm?.perm == '$e', onTap: () { diff --git a/lib/features/post_report/view/post_report_dialog.dart b/lib/features/post_report/view/post_report_dialog.dart index b60445200..ace019217 100644 --- a/lib/features/post_report/view/post_report_dialog.dart +++ b/lib/features/post_report/view/post_report_dialog.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/post_report/cubit/post_report_cubit.dart'; @@ -12,6 +13,7 @@ import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/browser_launcher.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Open the report dialog of [post] (#127). Does nothing when the forum offered no report for it. /// @@ -273,12 +275,16 @@ class _PostReportDialogState extends State { }; } + /// A status line: problems in an error banner, plain information as text. Widget _notice(BuildContext context, String text, {bool error = false}) => Padding( padding: const EdgeInsets.only(bottom: 8), - child: Text( - text, - style: error ? TextStyle(color: Theme.of(context).colorScheme.error) : null, - ), + child: error ? AppNoticeBanner(tone: AppNoticeTone.error, message: text) : Text(text), + ); + + /// Text the forum answered, quoted as is in an inset block so it reads apart from the app's own wording. + Widget _forumMessage(BuildContext context, String text) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AppInsetBlock(child: Text(text)), ); List _buildForm(BuildContext context, PostReportState state, PostReportForm form) { @@ -300,7 +306,7 @@ class _PostReportDialogState extends State { if (state.formChanged) _notice(context, tr.formChanged, error: true), if (state.phase == PostReportPhase.notSent && state.problem != null) ...[ _notice(context, _problemText(context.t, state.problem!), error: true), - if (state.message != null) _notice(context, state.message!), + if (state.message != null) _forumMessage(context, state.message!), ], if (state.phase == PostReportPhase.rejected) ...[ _notice( @@ -308,31 +314,36 @@ class _PostReportDialogState extends State { state.problem == PostReportProblem.notLoggedIn ? tr.problem.notLoggedIn : tr.rejected, error: true, ), - if (state.message?.isNotEmpty ?? false) _notice(context, state.message!), + if (state.message?.isNotEmpty ?? false) _forumMessage(context, state.message!), ], - Text(tr.reason, style: Theme.of(context).textTheme.titleSmall), - RadioGroup( - groupValue: _reason, - onChanged: (value) { - if (editable) { - setState(() => _reason = value); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final (index, label) in form.reasons.indexed) - RadioListTile( - key: ValueKey('post-report-reason-$index'), - value: index, - enabled: editable, - contentPadding: EdgeInsets.zero, - title: Text(label), - ), - ], + Text(tr.reason, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + sizedBoxW4H4, + AppInsetBlock( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: RadioGroup( + groupValue: _reason, + onChanged: (value) { + if (editable) { + setState(() => _reason = value); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final (index, label) in form.reasons.indexed) + RadioListTile( + key: ValueKey('post-report-reason-$index'), + value: index, + enabled: editable, + contentPadding: EdgeInsets.zero, + title: Text(label), + ), + ], + ), ), ), if (customSelected) ...[ + sizedBoxW12H12, TextField( key: const ValueKey('post-report-custom'), controller: _custom, @@ -341,7 +352,7 @@ class _PostReportDialogState extends State { maxLines: 5, decoration: InputDecoration( hintText: tr.customHint, - border: const OutlineInputBorder(), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), errorText: error, helperText: error == null ? tr.remaining(count: remaining) : null, helperMaxLines: 3, @@ -406,7 +417,7 @@ class _PostReportDialogState extends State { final problem = state.problem ?? PostReportProblem.unsupported; content = [ _notice(context, _problemText(context.t, problem), error: true), - if (state.message != null) _notice(context, state.message!), + if (state.message != null) _forumMessage(context, state.message!), ]; actions = [ browserButton, @@ -435,8 +446,11 @@ class _PostReportDialogState extends State { ]; case PostReportPhase.succeeded: content = [ - _notice(context, tr.succeeded), - if (state.message?.isNotEmpty ?? false) _notice(context, state.message!), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AppNoticeBanner(icon: Icons.check_circle_outline, message: tr.succeeded), + ), + if (state.message?.isNotEmpty ?? false) _forumMessage(context, state.message!), ]; actions = [closeButton]; case PostReportPhase.unknown: @@ -457,12 +471,30 @@ class _PostReportDialogState extends State { }, child: AlertDialog( scrollable: true, + icon: const Icon(Icons.flag_outlined), title: Text(tr.title(floor: widget.floorLabel)), content: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (!_accountLost) _notice(context, tr.author(name: widget.authorName)), + if (!_accountLost) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(Icons.person_outline, size: 18, color: Theme.of(context).colorScheme.outline), + sizedBoxW8H8, + Expanded( + child: Text( + tr.author(name: widget.authorName), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), ...content, if (_browserFailed) _notice(context, tr.browserFailed, error: true), ], diff --git a/lib/features/profile/bloc/current_title_cubit.dart b/lib/features/profile/bloc/current_title_cubit.dart new file mode 100644 index 000000000..b850d4539 --- /dev/null +++ b/lib/features/profile/bloc/current_title_cubit.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/profile/models/secondary_title.dart'; +import 'package:tsdm_client/features/profile/repository/my_titles_repository.dart'; +import 'package:tsdm_client/utils/logger.dart'; + +/// Loading status of the secondary title of the current account. +enum CurrentTitleStatus { + /// Nothing requested for [CurrentTitleState.uid] yet. + initial, + + /// Fetching the titles page. + loading, + + /// The current title is known, [CurrentTitleState.title] is null when none is used. + success, + + /// Fetching failed, a later [CurrentTitleCubit.ensureLoaded] tries again. + failure, +} + +/// The secondary title the current account is using, keyed by the account it was read for. +@immutable +final class CurrentTitleState { + /// Constructor. + const CurrentTitleState({this.uid, this.status = CurrentTitleStatus.initial, this.title}); + + /// Uid of the account [title] belongs to, null when no account is logged in. + final int? uid; + + /// Loading status. + final CurrentTitleStatus status; + + /// The title in use, from the `当前使用的称号` block of the titles page. + final SecondaryTitle? title; + + /// Image of [title] when it belongs to the account [uid], null otherwise. + /// + /// Callers pass the uid of the user they are showing, so the badge of the current account never shows up for + /// anybody else, nor for the previous account while switching. + String? imageUrlFor(int? uid) => uid != null && uid == this.uid ? title?.imageUrl : null; + + /// [status] when it is about the account [uid], [CurrentTitleStatus.initial] otherwise. + /// + /// Lets a badge tell "not known yet" or "failed to read" apart from "no title in use" for that account only. + CurrentTitleStatus statusFor(int? uid) => uid != null && uid == this.uid ? status : CurrentTitleStatus.initial; + + @override + bool operator ==(Object other) => + other is CurrentTitleState && other.uid == uid && other.status == status && other.title == title; + + @override + int get hashCode => Object.hash(uid, status, title); +} + +/// App level holder of the secondary title used by the current account. +/// +/// The forum renders the title of an author next to their posts, but the pages about the current account (homepage, +/// own profile) do not carry it: the only source is the title management page of the account itself +/// (`plugin.php?id=tsdmtitle:tsdmtitle`). That page is read once per account and only when a badge is about to be +/// shown ([ensureLoaded]); the my titles page hands over what it read or switched to ([record]) so nothing is fetched +/// twice. +/// +/// Every account change drops the known title at once and discards answers of requests started for the previous +/// account. +final class CurrentTitleCubit extends Cubit with LoggerMixin { + /// Constructor. + /// + /// [currentUid] is the verified current account, [authStatus] its changes; [fetchTitles] reads the titles page of + /// the current account (replaceable in tests). + CurrentTitleCubit({ + required int? Function() currentUid, + required Stream authStatus, + AsyncEither> Function()? fetchTitles, + }) : _currentUid = currentUid, + _fetchTitles = fetchTitles ?? _defaultFetchTitles, + super(const CurrentTitleState()) { + _authSub = authStatus.listen(_onAuthStatus); + } + + // A new repository per request: it caches the form hash of the session it was used in. + static AsyncEither> _defaultFetchTitles() => MyTitlesRepository().fetchSecondaryTitles(); + + final int? Function() _currentUid; + final AsyncEither> Function() _fetchTitles; + late final StreamSubscription _authSub; + + /// Bumped whenever the known title is replaced or dropped; a request finishing under another generation is stale. + int _generation = 0; + + void _reset(int? uid) { + _generation++; + emit(CurrentTitleState(uid: uid)); + } + + /// A login or an account switch is in progress: the cookie in use may already belong to the next account. + bool _authChanging = false; + + void _onAuthStatus(AuthStatus status) { + _authChanging = status is AuthStatusLoading; + switch (status) { + case AuthStatusAuthed(:final userInfo): + if (userInfo.uid != state.uid) { + _reset(userInfo.uid); + } + case AuthStatusNotAuthed() || AuthStatusLoading(): + // Logging out or switching: nothing about the previous account may stay on screen, and whatever is in flight + // for it is stale. + _reset(null); + case AuthStatusUnknown(): + break; + } + } + + /// Read the title of the current account unless it is known or being read; [force] reads it again. + Future ensureLoaded({bool force = false}) async { + final uid = _currentUid(); + if (_authChanging) { + return; + } + if (uid == null) { + if (state.uid != null) { + _reset(null); + } + return; + } + if (uid != state.uid) { + _reset(uid); + } + if (state.status == CurrentTitleStatus.loading || (!force && state.status == CurrentTitleStatus.success)) { + return; + } + final generation = _generation; + emit(CurrentTitleState(uid: uid, status: CurrentTitleStatus.loading, title: state.title)); + final result = await _fetchTitles().run(); + if (isClosed || generation != _generation || _currentUid() != uid) { + debug('drop the title read for uid $uid: the account changed meanwhile'); + return; + } + switch (result) { + case Right(:final value): + emit( + CurrentTitleState( + uid: uid, + status: CurrentTitleStatus.success, + title: value.firstWhereOrNull((e) => e.activated), + ), + ); + case Left(:final value): + handle(value); + emit(CurrentTitleState(uid: uid, status: CurrentTitleStatus.failure, title: state.title)); + } + } + + /// Take the [titles] the my titles page read or switched for the account [uid]. + /// + /// Ignored when [uid] is not the current account any more. + void record({required int? uid, required List titles}) { + if (uid == null || uid != _currentUid()) { + return; + } + _generation++; + emit( + CurrentTitleState( + uid: uid, + status: CurrentTitleStatus.success, + title: titles.firstWhereOrNull((e) => e.activated), + ), + ); + } + + @override + Future close() async { + await _authSub.cancel(); + await super.close(); + } +} diff --git a/lib/features/profile/bloc/my_titles_cubit.dart b/lib/features/profile/bloc/my_titles_cubit.dart index 6b7573f68..39ecddab5 100644 --- a/lib/features/profile/bloc/my_titles_cubit.dart +++ b/lib/features/profile/bloc/my_titles_cubit.dart @@ -53,9 +53,23 @@ final class MyTitlesState with MyTitlesStateMappable { /// Cubit of my titles page. final class MyTitlesCubit extends Cubit with LoggerMixin { /// Constructor. - MyTitlesCubit(this._repo) : super(const MyTitlesState()); + /// + /// [onTitlesChanged] receives the titles every time they are read or the current one changed, so the badge of the + /// current account elsewhere in the app follows without reading the page again. + MyTitlesCubit(this._repo, {void Function(List titles)? onTitlesChanged}) + : _onTitlesChanged = onTitlesChanged, + super(const MyTitlesState()); final MyTitlesRepository _repo; + final void Function(List titles)? _onTitlesChanged; + + @override + void onChange(Change change) { + super.onChange(change); + if (change.nextState.status == MyTitlesStatus.success) { + _onTitlesChanged?.call(change.nextState.titles); + } + } /// Fetch info about all secondary titles for current user. Future fetchAvailableSecondaryTitles() async { diff --git a/lib/features/profile/bloc/profile_bloc.dart b/lib/features/profile/bloc/profile_bloc.dart index 874e1749f..6fd27846c 100644 --- a/lib/features/profile/bloc/profile_bloc.dart +++ b/lib/features/profile/bloc/profile_bloc.dart @@ -55,6 +55,7 @@ class ProfileBloc extends Bloc with LoggerMixin { userProfile: v, unreadNoticeCount: unreadNoticeCount, hasUnreadMessage: hasUnreadMessage, + secondaryTitleUrl: parseProfileSecondaryTitleUrl(_profileRepository.getCache()!), ), ); }, @@ -88,6 +89,7 @@ class ProfileBloc extends Bloc with LoggerMixin { userProfile: v, unreadNoticeCount: unreadNoticeCount, hasUnreadMessage: hasUnreadMessage, + secondaryTitleUrl: parseProfileSecondaryTitleUrl(document), ), ); }, @@ -122,6 +124,7 @@ class ProfileBloc extends Bloc with LoggerMixin { userProfile: v, unreadNoticeCount: unreadNoticeCount, hasUnreadMessage: hasUnreadMessage, + secondaryTitleUrl: parseProfileSecondaryTitleUrl(document), ), ); }, diff --git a/lib/features/profile/bloc/profile_state.dart b/lib/features/profile/bloc/profile_state.dart index e2015eead..eab90c3d5 100644 --- a/lib/features/profile/bloc/profile_state.dart +++ b/lib/features/profile/bloc/profile_state.dart @@ -33,6 +33,7 @@ class ProfileState with ProfileStateMappable { this.failedToLogoutReason, this.unreadNoticeCount = 0, this.hasUnreadMessage = false, + this.secondaryTitleUrl, }); /// Status. @@ -59,7 +60,12 @@ class ProfileState with ProfileStateMappable { /// Flag indicating has unread messages or not. final bool hasUnreadMessage; + /// Secondary title image of the profile owner as rendered in the profile page, null when the page has none. + final String? secondaryTitleUrl; + /// Copy with. + /// + /// [secondaryTitleUrl] is kept when not given; pass it (null included) with every parsed page. ProfileState copyWith({ ProfileStatus? status, String? username, @@ -68,6 +74,7 @@ class ProfileState with ProfileStateMappable { AppException? failedToLogoutReason, int? unreadNoticeCount, bool? hasUnreadMessage, + Object? secondaryTitleUrl = _keep, }) { return ProfileState( status: status ?? this.status, @@ -78,6 +85,10 @@ class ProfileState with ProfileStateMappable { failedToLogoutReason: failedToLogoutReason, unreadNoticeCount: unreadNoticeCount ?? this.unreadNoticeCount, hasUnreadMessage: hasUnreadMessage ?? this.hasUnreadMessage, + secondaryTitleUrl: identical(secondaryTitleUrl, _keep) ? this.secondaryTitleUrl : secondaryTitleUrl as String?, ); } } + +/// Marks an argument of [ProfileState.copyWith] that was not given. +const Object _keep = Object(); diff --git a/lib/features/profile/utils/parse_profile.dart b/lib/features/profile/utils/parse_profile.dart index 320f8fae7..2c808a50b 100644 --- a/lib/features/profile/utils/parse_profile.dart +++ b/lib/features/profile/utils/parse_profile.dart @@ -50,6 +50,15 @@ String? parseProfileAvatarUrl(uh.Document document) { return node.imageUrl(); } +/// Parse the secondary title image of the profile owner in profile page [document], null when absent. +/// +/// Only the markup the title plugin is known to render is recognized, the block also found in the author column of +/// thread floors (`
`), and only inside the +/// profile content `div#ct`, never in the page header which describes the viewing account. Whether the forum renders +/// that block on profile pages is not established by any sample; when it does not, nothing is shown. +String? parseProfileSecondaryTitleUrl(uh.Document document) => + document.querySelector('div#ct')?.querySelector('div.tsdmtitle-badges div.tsdmtitle-title > img')?.imageUrl(); + /// Check whether the page [document] is rendered for a logged in user. /// /// Logged in pages have the user block `div#um` with username in `strong.vwmy` while guest pages have the login form diff --git a/lib/features/profile/view/edit_avatar_page.dart b/lib/features/profile/view/edit_avatar_page.dart index f7c74ffba..6748ed5dd 100644 --- a/lib/features/profile/view/edit_avatar_page.dart +++ b/lib/features/profile/view/edit_avatar_page.dart @@ -8,10 +8,9 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/indicator.dart'; -import 'package:tsdm_client/widgets/section_title_text.dart'; -import 'package:tsdm_client/widgets/tips.dart'; const _avatarMaxWidth = 100.0; const _avatarMaxHeight = 150.0; @@ -33,82 +32,123 @@ class _EditAvatarPageState extends State { Widget _buildContent(BuildContext context, EditAvatarState state) { final tr = context.t.editAvatarPage; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; - return ListView( - children: [ - SectionTitleText(tr.currentAvatar), - if (state.avatarUrl?.isNotEmpty ?? false) ...[ - Padding( - padding: edgeInsetsL12R12, - child: CachedImage(state.avatarUrl!, maxWidth: _avatarMaxWidth, maxHeight: _avatarMaxHeight), - ), - sizedBoxW12H12, - ] else ...[ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 50), + /// One avatar box: caption above, the image or a muted placeholder, same size either way. + Widget avatarBlock(String caption, String? url, IconData placeholderIcon, String? placeholderText) => AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(caption, style: textTheme.labelMedium?.copyWith(color: colorScheme.outline)), + sizedBoxW8H8, + SizedBox( + width: _avatarMaxWidth + 20, + height: _avatarMaxHeight, child: Center( - child: Text( - tr.noAvatar, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), + child: url != null + ? CachedImage(url, maxWidth: _avatarMaxWidth, maxHeight: _avatarMaxHeight) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(placeholderIcon, size: 32, color: colorScheme.outline), + if (placeholderText != null) ...[ + sizedBoxW4H4, + Text( + placeholderText, + textAlign: TextAlign.center, + style: textTheme.bodySmall?.copyWith(color: colorScheme.outline), + ), + ], + ], + ), ), ), - sizedBoxW12H12, ], - Padding( - padding: edgeInsetsL12R12, - child: TextField( - controller: _avatarController, - decoration: InputDecoration(labelText: tr.avatarUrl), + ), + ); + + // Form pages stay at most [appFormMaxWidth] wide, centered on wide windows. The current avatar and the preview + // sit side by side (wrapping on narrow phones), so the change is compared before it is submitted. + return AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 24), + children: [ + AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader(tr.currentAvatar, icon: Icons.account_circle_outlined), + Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: [ + avatarBlock( + tr.currentAvatar, + (state.avatarUrl?.isNotEmpty ?? false) ? state.avatarUrl : null, + Icons.no_photography_outlined, + tr.noAvatar, + ), + avatarBlock(tr.preview, _previewUrl, Icons.preview_outlined, null), + ], + ), + ], + ), ), - ), - sizedBoxW12H12, - Padding( - padding: edgeInsetsL12R12, - child: Row( + const SizedBox(height: appSurfaceGap), + AppFormSection( + title: tr.avatarUrl, + icon: Icons.link_outlined, children: [ - Expanded( - child: FilledButton.tonal( - onPressed: state.status == EditAvatarStatus.uploading || _avatarController.text.isEmpty - ? null - : () => setState(() => _previewUrl = _avatarController.text), - child: Text(tr.preview), - ), + TextField( + controller: _avatarController, + decoration: appFieldDecoration(label: tr.avatarUrl, icon: Icons.image_outlined), + ), + Row( + children: [ + Expanded( + child: FilledButton.tonalIcon( + icon: const Icon(Icons.preview_outlined), + onPressed: state.status == EditAvatarStatus.uploading || _avatarController.text.isEmpty + ? null + : () => setState(() => _previewUrl = _avatarController.text), + label: Text(tr.preview), + ), + ), + sizedBoxW8H8, + Expanded( + child: FilledButton.icon( + icon: state.status == EditAvatarStatus.uploading + ? sizedCircularProgressIndicator + : const Icon(Icons.cloud_upload_outlined), + onPressed: state.formHash == null || state.status == EditAvatarStatus.uploading + ? null + : () { + context.read().add( + EditAvatarUploadRequested(avatarUrl: _avatarController.text, formHash: state.formHash!), + ); + }, + label: Text(tr.submit), + ), + ), + ], ), - sizedBoxW8H8, - Expanded( - child: FilledButton( - onPressed: state.formHash == null || state.status == EditAvatarStatus.uploading - ? null - : () { - context.read().add( - EditAvatarUploadRequested(avatarUrl: _avatarController.text, formHash: state.formHash!), - ); - }, - child: Text(tr.submit), + AppNoticeBanner(message: tr.clearAvatarTip), + Align( + child: TextButton.icon( + icon: const Icon(Icons.collections_outlined), + label: Text(tr.viewSharedAvatars), + onPressed: () async => context.pushNamed(ScreenPaths.threadV1, queryParameters: {'tid': '1106488'}), ), ), ], ), - ), - sizedBoxW12H12, - Align( - child: TextButton( - child: Text(tr.viewSharedAvatars, style: const TextStyle(decoration: TextDecoration.underline)), - onPressed: () async => context.pushNamed(ScreenPaths.threadV1, queryParameters: {'tid': '1106488'}), - ), - ), - sizedBoxW12H12, - Tips(tr.clearAvatarTip), - - if (_previewUrl != null) ...[ - SectionTitleText(tr.preview), - Padding( - padding: edgeInsetsL12R12, - child: CachedImage(_previewUrl!, maxWidth: _avatarMaxWidth, maxHeight: _avatarMaxHeight), - ), ], - ], + ), ); } diff --git a/lib/features/profile/view/edit_user_profile_page.dart b/lib/features/profile/view/edit_user_profile_page.dart index 815fc0cd3..25b406f30 100644 --- a/lib/features/profile/view/edit_user_profile_page.dart +++ b/lib/features/profile/view/edit_user_profile_page.dart @@ -19,6 +19,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; @@ -50,10 +51,12 @@ class _EditUserProfilePageState extends State { required List<(Value, String)> valueNamePairs, required eup.UserProfile Function(eup.UserProfile, Value) onValueUpdated, Value? currentValue, + IconData? icon, }) async { final newValue = await _showSelectionDialog( context: context, title: title, + icon: icon, currentValue: currentValue, valueNamePairs: valueNamePairs, ); @@ -78,10 +81,12 @@ class _EditUserProfilePageState extends State { InputDecoration? inputDecoration, int? maxLines = 1, int? minLines, + IconData? icon, }) async { final newValue = await _showTextFieldDialog( context: context, title: title, + icon: icon, profile: profile, initialText: currentValue, onValueUpdated: onValueUpdated, @@ -101,346 +106,345 @@ class _EditUserProfilePageState extends State { ); } + /// A text field of the profile: the row, and its dialog with the same icon. + Widget _textTile({ + required BuildContext context, + required eup.UserProfile profile, + required IconData icon, + required String title, + required String? value, + required eup.UserProfile Function(eup.UserProfile, String) onValueUpdated, + eup.Visibility? visibility, + FutureOr Function(eup.Visibility)? onVisibilityChanged, + bool multiline = false, + }) => _buildProfileListTile( + context: context, + icon: icon, + title: title, + subtitle: value ?? '', + value: value, + visibility: visibility, + onVisibilityChanged: onVisibilityChanged, + onTap: (v) async => _spawnTextFieldDialog( + context: context, + profile: profile, + title: title, + icon: icon, + currentValue: v ?? '', + onValueUpdated: onValueUpdated, + maxLines: multiline ? _profileFieldTextMaxLines : 1, + minLines: multiline ? _profileFieldTextMinLines : null, + ), + ); + + void _save(BuildContext context, eup.UserProfile profile) => + context.read().add(EditUserProfileSaveProfileRequested(profile)); + Widget _buildBody(BuildContext context, eup.UserProfile profile) { final tr = context.t.editUserProfilePage; - return Form( - key: formKey, - child: ListView( - padding: context.safePadding(), - children: [ - _buildProfileListTile( - context: context, - title: tr.username, - value: tr.username, - subtitle: profile.usernameReadonly, - ), - _buildProfileListTile( + // Same fields, dialogs and values as before, grouped by what they are about. + final basic = AppTileGroup( + title: tr.sections.basic, + icon: Icons.person_outline, + children: [ + _buildProfileListTile( + context: context, + icon: Icons.badge_outlined, + title: tr.username, + value: tr.username, + subtitle: profile.usernameReadonly, + ), + _buildProfileListTile( + context: context, + icon: Icons.face_2_outlined, + title: tr.gender.title, + subtitle: switch (profile.gender) { + eup.Gender.private => tr.gender.hide, + eup.Gender.male => tr.gender.male, + eup.Gender.female => tr.gender.female, + }, + value: profile.gender, + visibility: profile.genderVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(genderVisibility: visibility)), + onTap: (gender) async => _spawnSelectionDialog( context: context, + profile: profile, title: tr.gender.title, - subtitle: switch (profile.gender) { - eup.Gender.private => tr.gender.hide, - eup.Gender.male => tr.gender.male, - eup.Gender.female => tr.gender.female, - }, - value: profile.gender, - visibility: profile.genderVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(genderVisibility: visibility)), - ), - onTap: (gender) async => _spawnSelectionDialog( - context: context, - profile: profile, - title: tr.gender.title, - currentValue: profile.gender, - valueNamePairs: [ - (eup.Gender.private, tr.gender.hide), - (eup.Gender.male, tr.gender.male), - (eup.Gender.female, tr.gender.female), - ], - onValueUpdated: (p, v) => p.copyWith(gender: v), - ), + icon: Icons.face_2_outlined, + currentValue: profile.gender, + valueNamePairs: [ + (eup.Gender.private, tr.gender.hide), + (eup.Gender.male, tr.gender.male), + (eup.Gender.female, tr.gender.female), + ], + onValueUpdated: (p, v) => p.copyWith(gender: v), ), - _buildProfileListTile( + ), + _buildProfileListTile( + context: context, + icon: Icons.cake_outlined, + title: tr.birthday.title, + subtitle: + '${profile.birthdayYear ?? "-"} ${tr.birthday.year} ' + '${profile.birthdayMonth ?? "-"} ${tr.birthday.month} ' + '${profile.birthdayDay ?? "-"} ${tr.birthday.day}', + value: _BirthdayInfo( + year: profile.birthdayYear, + month: profile.birthdayMonth, + day: profile.birthdayDay, + availableYears: profile.birthdayAvailableYears, + ), + visibility: profile.birthdayVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(birthdayVisibility: visibility)), + onTap: (_) async { + final birthday = await showSelectBirthdayDialog( + context, + BirthdayInfo( + year: profile.birthdayYear, + month: profile.birthdayMonth, + day: profile.birthdayDay, + ), + profile.birthdayAvailableYears, + ); + if (birthday == null || !context.mounted) { + return; + } + _save( + context, + profile.copyWith( + birthdayYear: birthday.year, + birthdayMonth: birthday.month, + birthdayDay: birthday.day, + ), + ); + }, + ), + _textTile( + context: context, + profile: profile, + icon: Icons.location_on_outlined, + title: tr.location, + value: profile.location, + visibility: profile.locationVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(locationVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(location: v), + ), + _textTile( + context: context, + profile: profile, + icon: Icons.alternate_email_outlined, + title: tr.nickname, + value: profile.nickname, + visibility: profile.nicknameVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(nicknameVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(nickname: v), + ), + _buildProfileListTile( + context: context, + icon: Icons.schedule_outlined, + title: tr.timezone, + value: _TimeZoneInfo(profile.timeZone, profile.availableTimeZones), + subtitle: profile.timeZone?.name ?? '-', + onTap: (gender) async => _spawnSelectionDialog( context: context, - title: tr.birthday.title, - subtitle: - '${profile.birthdayYear ?? "-"} ${tr.birthday.year} ' - '${profile.birthdayMonth ?? "-"} ${tr.birthday.month} ' - '${profile.birthdayDay ?? "-"} ${tr.birthday.day}', - value: _BirthdayInfo( - year: profile.birthdayYear, - month: profile.birthdayMonth, - day: profile.birthdayDay, - availableYears: profile.birthdayAvailableYears, - ), - visibility: profile.birthdayVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(birthdayVisibility: visibility)), + profile: profile, + title: tr.timezone, + icon: Icons.schedule_outlined, + currentValue: profile.timeZone?.value, + valueNamePairs: profile.availableTimeZones.map((v) => (v.value, v.name)).toList(), + onValueUpdated: (p, v) => p.copyWith( + timeZone: profile.availableTimeZones.firstWhereOrNull((e) => e.value == v), ), - onTap: (_) async { - final birthday = await showSelectBirthdayDialog( - context, - BirthdayInfo( - year: profile.birthdayYear, - month: profile.birthdayMonth, - day: profile.birthdayDay, - ), - profile.birthdayAvailableYears, - ); - if (birthday == null || !context.mounted) { - return; - } - context.read().add( - EditUserProfileSaveProfileRequested( - profile.copyWith( - birthdayYear: birthday.year, - birthdayMonth: birthday.month, - birthdayDay: birthday.day, - ), - ), - ); - }, ), - _buildProfileListTile( + ), + ], + ); + + final contact = AppTileGroup( + title: tr.sections.contact, + icon: Icons.contact_mail_outlined, + children: [ + _buildProfileListTile( + context: context, + icon: Icons.chat_outlined, + title: tr.qq.title, + subtitle: '${profile.qq ?? ""}', + value: profile.qq, + visibility: profile.qqVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(qqVisibility: visibility)), + onTap: (v) async => _spawnTextFieldDialog( context: context, + profile: profile, title: tr.qq.title, - subtitle: '${profile.qq ?? ""}', - value: profile.qq, - visibility: profile.qqVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(qqVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.qq.title, - currentValue: v == null ? '' : v.toString(), - onValueUpdated: (p, v) => p.copyWith(qq: v.parseToInt()), - validator: (v) { - if (v == null) { - return tr.qq.invalidQQ; - } - - if (v.isEmpty) { - return null; - } - - if (v.startsWith('0') || !RegExp('[0-9]{6,}').hasMatch(v)) { - return tr.qq.invalidQQ; - } - - final vv = v.parseToInt(); - if (vv == null || vv < 0) { - return tr.qq.invalidQQ; - } + icon: Icons.chat_outlined, + currentValue: v == null ? '' : v.toString(), + onValueUpdated: (p, v) => p.copyWith(qq: v.parseToInt()), + validator: (v) { + if (v == null) { + return tr.qq.invalidQQ; + } + if (v.isEmpty) { return null; - }, - keyboardType: .number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[0-9]+'))], - ), - ), - _buildProfileListTile( - context: context, - title: tr.msn, - subtitle: profile.msn ?? '', - value: profile.msn, - visibility: profile.msnVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(msnVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.msn, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(msn: v), - ), - ), - _buildProfileListTile( - context: context, - title: tr.homepage, - subtitle: profile.homepage ?? '', - value: profile.homepage, - visibility: profile.homepageVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(homepageVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.homepage, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(homepage: v), - ), - ), - _buildProfileListTile( - context: context, - title: tr.bio, - subtitle: profile.bio, - value: profile.bio, - visibility: profile.bioVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(bioVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.bio, - currentValue: v, - onValueUpdated: (p, v) => p.copyWith(bio: v), - maxLines: _profileFieldTextMaxLines, - minLines: _profileFieldTextMinLines, - ), - ), - _buildProfileListTile( - context: context, - title: tr.hobby, - subtitle: profile.hobby ?? '', - value: profile.hobby, - visibility: profile.hobbyVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(hobbyVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.hobby, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(hobby: v), - maxLines: _profileFieldTextMaxLines, - minLines: _profileFieldTextMinLines, - ), - ), - _buildProfileListTile( - context: context, - title: tr.location, - subtitle: profile.location ?? '', - value: profile.location, - visibility: profile.locationVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(locationVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.location, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(location: v), - ), - ), - _buildProfileListTile( - context: context, - title: tr.nickname, - subtitle: profile.nickname ?? '', - value: profile.nickname, - visibility: profile.nicknameVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(nicknameVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.nickname, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(nickname: v), - ), - ), - _buildProfileListTile( - context: context, - title: tr.wordsToSay, - subtitle: profile.wordsToSay ?? '', - value: profile.wordsToSay, - visibility: profile.wordsToSayVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(wordsToSayVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.wordsToSay, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(wordsToSay: v), - ), - ), - _buildProfileListTile( - context: context, - title: tr.skills, - subtitle: profile.skill ?? '', - value: profile.skill, - visibility: profile.skillVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(skillVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.skills, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(skill: v), - ), + } + + if (v.startsWith('0') || !RegExp('[0-9]{6,}').hasMatch(v)) { + return tr.qq.invalidQQ; + } + + final vv = v.parseToInt(); + if (vv == null || vv < 0) { + return tr.qq.invalidQQ; + } + + return null; + }, + keyboardType: .number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp('[0-9]+'))], ), + ), + _textTile( + context: context, + profile: profile, + icon: Icons.forum_outlined, + title: tr.msn, + value: profile.msn, + visibility: profile.msnVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(msnVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(msn: v), + ), + _textTile( + context: context, + profile: profile, + icon: Icons.home_outlined, + title: tr.homepage, + value: profile.homepage, + visibility: profile.homepageVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(homepageVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(homepage: v), + ), + ], + ); + + final aboutMe = AppTileGroup( + title: tr.sections.aboutMe, + icon: Icons.notes_outlined, + children: [ + _textTile( + context: context, + profile: profile, + icon: Icons.description_outlined, + title: tr.bio, + value: profile.bio, + visibility: profile.bioVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(bioVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(bio: v), + multiline: true, + ), + _textTile( + context: context, + profile: profile, + icon: Icons.interests_outlined, + title: tr.hobby, + value: profile.hobby, + visibility: profile.hobbyVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(hobbyVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(hobby: v), + multiline: true, + ), + _textTile( + context: context, + profile: profile, + icon: Icons.format_quote_outlined, + title: tr.wordsToSay, + value: profile.wordsToSay, + visibility: profile.wordsToSayVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(wordsToSayVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(wordsToSay: v), + ), + _textTile( + context: context, + profile: profile, + icon: Icons.construction_outlined, + title: tr.skills, + value: profile.skill, + visibility: profile.skillVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(skillVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(skill: v), + ), + _textTile( + context: context, + profile: profile, + icon: Icons.live_tv_outlined, + title: tr.favoriteBangumi, + value: profile.favoriteBangumi, + visibility: profile.favoriteBangumiVisibility, + onVisibilityChanged: (visibility) => _save(context, profile.copyWith(favoriteBangumiVisibility: visibility)), + onValueUpdated: (p, v) => p.copyWith(favoriteBangumi: v), + ), + ], + ); + + final forum = AppTileGroup( + title: tr.sections.forum, + icon: Icons.forum_outlined, + children: [ + _textTile( + context: context, + profile: profile, + icon: Icons.military_tech_outlined, + title: tr.customTitle, + value: profile.customTitle, + onValueUpdated: (p, v) => p.copyWith(customTitle: v), + ), + _textTile( + context: context, + profile: profile, + icon: Icons.draw_outlined, + title: tr.signature, + value: profile.signature, + onValueUpdated: (p, v) => p.copyWith(signature: v), + multiline: true, + ), + // Page style is not editable on Discuz X5 (no "styleid" select in form), hide it if no style available. + if (profile.availablePageStyles.isNotEmpty) _buildProfileListTile( context: context, - title: tr.favoriteBangumi, - subtitle: profile.favoriteBangumi ?? '', - value: profile.favoriteBangumi, - visibility: profile.favoriteBangumiVisibility, - onVisibilityChanged: (visibility) => context.read().add( - EditUserProfileSaveProfileRequested(profile.copyWith(favoriteBangumiVisibility: visibility)), - ), - onTap: (v) async => _spawnTextFieldDialog( + icon: Icons.palette_outlined, + title: tr.pageStyle.title, + value: _PageStyleInfo(profile.pageStyle, profile.availablePageStyles), + subtitle: '${profile.pageStyle?.name ?? '-'} · ${tr.pageStyle.tip}', + onTap: (gender) async => _spawnSelectionDialog( context: context, profile: profile, - title: tr.favoriteBangumi, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(favoriteBangumi: v), - ), - ), - // Page style is not editable on Discuz X5 (no "styleid" select in form), hide it if no style available. - if (profile.availablePageStyles.isNotEmpty) - _buildProfileListTile( - context: context, title: tr.pageStyle.title, - value: _PageStyleInfo(profile.pageStyle, profile.availablePageStyles), - subtitle: profile.pageStyle?.name ?? '-', - onTap: (gender) async => _spawnSelectionDialog( - context: context, - profile: profile, - title: tr.pageStyle.title, - currentValue: profile.pageStyle?.value ?? 0, - valueNamePairs: profile.availablePageStyles.map((v) => (v.value, v.name)).toList(), - onValueUpdated: (p, v) => p.copyWith( - pageStyle: profile.availablePageStyles.firstWhereOrNull((e) => e.value == v), - ), - ), - ), - _buildProfileListTile( - context: context, - title: tr.customTitle, - value: profile.customTitle, - subtitle: profile.customTitle ?? '', - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.customTitle, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(customTitle: v), - ), - ), - _buildProfileListTile( - context: context, - title: tr.signature, - value: profile.signature, - subtitle: profile.signature ?? '', - onTap: (v) async => _spawnTextFieldDialog( - context: context, - profile: profile, - title: tr.signature, - currentValue: v ?? '', - onValueUpdated: (p, v) => p.copyWith(signature: v), - maxLines: _profileFieldTextMaxLines, - minLines: _profileFieldTextMinLines, - ), - ), - _buildProfileListTile( - context: context, - title: tr.timezone, - value: _TimeZoneInfo(profile.timeZone, profile.availableTimeZones), - subtitle: profile.timeZone?.name ?? '-', - onTap: (gender) async => _spawnSelectionDialog( - context: context, - profile: profile, - title: tr.timezone, - currentValue: profile.timeZone?.value, - valueNamePairs: profile.availableTimeZones.map((v) => (v.value, v.name)).toList(), + icon: Icons.palette_outlined, + currentValue: profile.pageStyle?.value ?? 0, + valueNamePairs: profile.availablePageStyles.map((v) => (v.value, v.name)).toList(), onValueUpdated: (p, v) => p.copyWith( - timeZone: profile.availableTimeZones.firstWhereOrNull((e) => e.value == v), + pageStyle: profile.availablePageStyles.firstWhereOrNull((e) => e.value == v), ), ), ), - ], + ], + ); + + return Form( + key: formKey, + child: AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 24).add(context.safePadding()), + children: [ + // Edits stay on this page until uploaded with the button of the app bar. + AppNoticeBanner(message: tr.uploadHint, icon: Icons.cloud_upload_outlined), + for (final group in [basic, contact, aboutMe, forum]) ...[ + const SizedBox(height: appSurfaceGap), + group, + ], + ], + ), ), ); } @@ -472,6 +476,7 @@ class _EditUserProfilePageState extends State { context, () => context.read().add(const EditUserProfileLoadProfileRequested()), ), + // Form pages stay at most [appFormMaxWidth] wide, centered on wide windows. _ => _buildBody(context, state.profile!), }; @@ -492,7 +497,7 @@ class _EditUserProfilePageState extends State { ), ], ), - body: body, + body: SafeArea(top: false, bottom: false, child: body), ); }, ), @@ -502,55 +507,64 @@ class _EditUserProfilePageState extends State { Widget _buildProfileListTile({ required BuildContext context, + required IconData icon, required String title, required String subtitle, required T value, eup.Visibility? visibility, FutureOr Function(eup.Visibility)? onVisibilityChanged, FutureOr Function(T)? onTap, -}) => ListTile( - title: Text(title), - subtitle: SingleLineText(subtitle), - titleTextStyle: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), - subtitleTextStyle: Theme.of(context).textTheme.bodyLarge?.copyWith(color: Theme.of(context).colorScheme.onSurface), - onTap: onTap == null || context.read().state.status == .submitting - ? null - : () async => onTap.call(value), - contentPadding: edgeInsetsL16R16, - trailing: visibility == null - ? null - : IconButton( - icon: Icon(switch (visibility) { - eup.Visibility.public => Symbols.visibility, - eup.Visibility.friendsOnly => Symbols.visibility_lock, - eup.Visibility.private => Symbols.visibility_off, - }), - onPressed: context.read().state.status == .submitting - ? null - : () async { - final tr = context.t.editUserProfilePage.visibility; - final v = await _showSelectionDialog( - context: context, - title: '${tr.title} - $title', - currentValue: visibility, - valueNamePairs: [ - (eup.Visibility.public, tr.public), - (eup.Visibility.friendsOnly, tr.friendsOnly), - (eup.Visibility.private, tr.private), - ], - ); - if (v == null || !context.mounted) { - return; - } - await onVisibilityChanged?.call(v); - }, - tooltip: switch (visibility) { - eup.Visibility.public => context.t.editUserProfilePage.visibility.public, - eup.Visibility.friendsOnly => context.t.editUserProfilePage.visibility.friendsOnly, - eup.Visibility.private => context.t.editUserProfilePage.visibility.private, - }, - ), -); +}) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + leading: Icon(icon), + title: Text(title), + // An empty field says so instead of leaving a blank line. + subtitle: SingleLineText(subtitle.trim().isEmpty ? '-' : subtitle), + titleTextStyle: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.secondary), + subtitleTextStyle: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: subtitle.trim().isEmpty ? colorScheme.outline : colorScheme.onSurface, + ), + onTap: onTap == null || context.read().state.status == .submitting + ? null + : () async => onTap.call(value), + contentPadding: edgeInsetsL16R16, + trailing: visibility == null + ? null + : IconButton( + icon: Icon(switch (visibility) { + eup.Visibility.public => Symbols.visibility, + eup.Visibility.friendsOnly => Symbols.visibility_lock, + eup.Visibility.private => Symbols.visibility_off, + }), + onPressed: context.read().state.status == .submitting + ? null + : () async { + final tr = context.t.editUserProfilePage.visibility; + final v = await _showSelectionDialog( + context: context, + title: '${tr.title} - $title', + icon: Symbols.visibility_lock, + currentValue: visibility, + valueNamePairs: [ + (eup.Visibility.public, tr.public), + (eup.Visibility.friendsOnly, tr.friendsOnly), + (eup.Visibility.private, tr.private), + ], + ); + if (v == null || !context.mounted) { + return; + } + await onVisibilityChanged?.call(v); + }, + tooltip: switch (visibility) { + eup.Visibility.public => context.t.editUserProfilePage.visibility.public, + eup.Visibility.friendsOnly => context.t.editUserProfilePage.visibility.friendsOnly, + eup.Visibility.private => context.t.editUserProfilePage.visibility.private, + }, + ), + ); +} /// Show a dialog provides a list of choices. /// @@ -565,6 +579,7 @@ Future _showSelectionDialog({ required String title, required List<(Value, String)> valueNamePairs, Value? currentValue, + IconData? icon, }) async => showDialog( context: context, builder: (context) => RootPage( @@ -572,7 +587,7 @@ Future _showSelectionDialog({ CustomAlertDialog.sync( clipBehavior: Clip.hardEdge, contentPadding: .zero, - title: Text(title), + title: icon == null ? Text(title) : AppDialogTitle(icon: icon, title: title), content: Column( children: valueNamePairs .map( @@ -606,6 +621,7 @@ Future _showTextFieldDialog({ InputDecoration? inputDecoration, int? maxLines, int? minLines, + IconData? icon, }) async => showDialog( context: context, builder: (context) => RootPage( @@ -613,6 +629,7 @@ Future _showTextFieldDialog({ _TextFieldDialog( profile: profile, title: title, + icon: icon, initialText: initialText, onValueUpdated: onValueUpdated, validator: validator, @@ -631,6 +648,7 @@ class _TextFieldDialog extends StatefulWidget { required this.title, required this.initialText, required this.onValueUpdated, + this.icon, this.validator, this.keyboardType, this.inputFormatters, @@ -645,6 +663,9 @@ class _TextFieldDialog extends StatefulWidget { /// Dialog title. final String title; + /// Icon of the field, shown in the title. + final IconData? icon; + /// Initial text. final String initialText; @@ -684,7 +705,7 @@ class _TextFieldDialogState extends State<_TextFieldDialog> { Widget build(BuildContext context) { return CustomAlertDialog.sync( clipBehavior: Clip.hardEdge, - title: Text(widget.title), + title: widget.icon == null ? Text(widget.title) : AppDialogTitle(icon: widget.icon!, title: widget.title), content: Form( key: formKey, child: Padding( @@ -695,7 +716,8 @@ class _TextFieldDialogState extends State<_TextFieldDialog> { validator: widget.validator, keyboardType: widget.keyboardType, inputFormatters: widget.inputFormatters, - decoration: widget.inputDecoration, + // Filled and rounded like the other forms; a caller's decoration still wins. + decoration: widget.inputDecoration ?? appFieldDecoration(), maxLines: widget.maxLines, minLines: widget.minLines, ), @@ -706,7 +728,7 @@ class _TextFieldDialogState extends State<_TextFieldDialog> { child: Text(context.t.general.cancel), onPressed: () => Navigator.of(context).pop(), ), - TextButton( + FilledButton( child: Text(context.t.general.ok), onPressed: () async { // Validate diff --git a/lib/features/profile/view/my_titles_page.dart b/lib/features/profile/view/my_titles_page.dart index a67fdd107..e8a3527b2 100644 --- a/lib/features/profile/view/my_titles_page.dart +++ b/lib/features/profile/view/my_titles_page.dart @@ -6,15 +6,24 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; import 'package:tsdm_client/features/profile/bloc/my_titles_cubit.dart'; import 'package:tsdm_client/features/profile/repository/my_titles_repository.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; import 'package:tsdm_client/features/profile/widgets/secondary_title_card.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; -import 'package:tsdm_client/widgets/single_line_text.dart'; + +/// Preferred width of a title card; the grid fits as many as the width allows. +const _cardExtent = 208.0; + +/// Spacing between title cards. +const _cardSpacing = 12.0; /// Page showing current user's titles. /// @@ -38,7 +47,14 @@ class _MyTitlesPageState extends State { ), BlocProvider( create: (context) { - final cubit = MyTitlesCubit(context.repo()); + // The page belongs to the account logged in when it opened: hand what it reads over to the current title + // badge only for that account. + final uid = context.readOrNull()?.currentUser?.uid; + final currentTitle = context.readOrNull(); + final cubit = MyTitlesCubit( + context.repo(), + onTitlesChanged: currentTitle == null ? null : (titles) => currentTitle.record(uid: uid, titles: titles), + ); unawaited(cubit.fetchAvailableSecondaryTitles()); return cubit; }, @@ -51,7 +67,6 @@ class _MyTitlesPageState extends State { } }, builder: (context, state) { - final currentTitleName = state.titles.firstWhereOrNull((v) => v.activated)?.name; final body = switch (state.status) { MyTitlesStatus.initial || MyTitlesStatus.loadingTitles => const CenteredCircularIndicator(), MyTitlesStatus.failure when state.titles.isEmpty => buildRetryButton( @@ -61,40 +76,47 @@ class _MyTitlesPageState extends State { MyTitlesStatus.switchingTitle || MyTitlesStatus.success || MyTitlesStatus.failure => ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: SingleChildScrollView( - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Row( - children: [ - sizedBoxW12H12, - Icon( - Icons.lightbulb_outline, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - sizedBoxW4H4, - Expanded( - child: SingleLineText( - currentTitleName == null ? tr.noneActivated : tr.activated(name: currentTitleName), - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), + padding: edgeInsetsL16T16R16B16, + child: AppContentWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CurrentTitleHeader(state), + sizedBoxW16H16, + if (state.titles.isEmpty) + Padding( + padding: edgeInsetsT12, + child: Text( + tr.empty, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), ), - if (currentTitleName != null) ...[ - sizedBoxW8H8, - TextButton( - child: Text(tr.unset), - onPressed: () async => context.read().unsetSecondaryTitle(), - ), - ], - ], - ), - ), - ...state.titles.map(SecondaryTitleCard.new), - ], + ) + else + LayoutBuilder( + builder: (context, constraints) { + final columns = ((constraints.maxWidth + _cardSpacing) / (_cardExtent + _cardSpacing)) + .floor() + .clamp(1, 8); + final width = (constraints.maxWidth - _cardSpacing * (columns - 1)) / columns; + return Wrap( + spacing: _cardSpacing, + runSpacing: _cardSpacing, + children: state.titles + .map( + (e) => SizedBox( + width: width, + child: SecondaryTitleCard(e, key: ValueKey(e.id)), + ), + ) + .toList(), + ); + }, + ), + ], + ), ), ), ), @@ -123,3 +145,77 @@ class _MyTitlesPageState extends State { ); } } + +/// The title in use with its image, the unset action and a hint on how to switch. +class _CurrentTitleHeader extends StatelessWidget { + const _CurrentTitleHeader(this.state); + + final MyTitlesState state; + + @override + Widget build(BuildContext context) { + final tr = context.t.myTitlesPage; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final current = state.titles.firstWhereOrNull((v) => v.activated); + final description = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + current == null ? tr.noneActivated : tr.activated(name: current.name), + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (state.titles.isNotEmpty) ...[ + sizedBoxW4H4, + Text(tr.tapToUse, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ], + ); + final unset = current == null + ? null + : TextButton( + onPressed: state.status == MyTitlesStatus.switchingTitle + ? null + : () async => context.read().unsetSecondaryTitle(), + child: Text(tr.unset), + ); + return AppSurface( + color: colorScheme.surfaceContainerLow, + child: LayoutBuilder( + builder: (context, constraints) { + // The title in use at its natural 184px width when there is room, complete in any case. + final badgeWidth = SecondaryTitleBadge.fitWidth(constraints.maxWidth); + final Widget leading = current != null + ? SecondaryTitleBadge(current.imageUrl, width: badgeWidth, semanticLabel: current.name) + : Icon(Icons.lightbulb_outline, color: colorScheme.onSurfaceVariant); + // Side by side only when the text keeps a readable width next to the badge. + if (current == null || constraints.maxWidth - badgeWidth >= 240) { + return Row( + children: [ + leading, + sizedBoxW12H12, + Expanded(child: description), + if (unset != null) ...[sizedBoxW8H8, unset], + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + leading, + sizedBoxW12H12, + Row( + children: [ + Expanded(child: description), + if (unset != null) ...[sizedBoxW8H8, unset], + ], + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/profile/view/profile_page.dart b/lib/features/profile/view/profile_page.dart index 5eb54871b..f1e69005e 100644 --- a/lib/features/profile/view/profile_page.dart +++ b/lib/features/profile/view/profile_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:dart_bbcode_web_colors/dart_bbcode_web_colors.dart'; @@ -12,7 +13,6 @@ import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/extensions/date_time.dart'; -import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/extensions/universal_html.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; @@ -21,9 +21,11 @@ import 'package:tsdm_client/features/checkin/bloc/checkin_bloc.dart'; import 'package:tsdm_client/features/checkin/widgets/checkin_button.dart'; import 'package:tsdm_client/features/friend/widgets/add_friend_dialog.dart'; import 'package:tsdm_client/features/need_login/view/need_login_page.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; import 'package:tsdm_client/features/profile/bloc/profile_bloc.dart'; import 'package:tsdm_client/features/profile/repository/profile_repository.dart'; import 'package:tsdm_client/features/profile/utils/parse_profile.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/medal.dart'; @@ -33,12 +35,11 @@ import 'package:tsdm_client/utils/html/html_muncher.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; -import 'package:tsdm_client/widgets/attr_block.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart'; import 'package:tsdm_client/widgets/debounce_buttons.dart'; import 'package:tsdm_client/widgets/heroes.dart'; -import 'package:tsdm_client/widgets/icon_chip.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/medal_group_view.dart'; import 'package:tsdm_client/widgets/notice_button.dart'; @@ -53,8 +54,6 @@ const _appBarBackgroundImageHeight = 80.0; const _appBarAvatarHeight = 80.0; const double _appBarExpandHeight = _appBarBackgroundImageHeight + _appBarAvatarHeight + _appBarBackgroundTopPadding; -const _groupAvatarHeight = 100.0; - /// All checking days required from current level to next level. /// /// Data: @@ -142,6 +141,7 @@ class _ProfilePageState extends State { Widget _buildSliverAppBar(BuildContext context, ProfileState state, {required bool logout}) { final tr = context.t.profilePage; final userProfile = state.userProfile!; + final colorScheme = Theme.of(context).colorScheme; if (!context.mounted) { return sizedBoxEmpty; @@ -209,6 +209,8 @@ class _ProfilePageState extends State { await context.pushNamed(ScreenPaths.userBlock); } }, + // Same items and actions, grouped: activity, own profile, account level, then logout apart in the error + // color. itemBuilder: (context) => [ PopupMenuItem( value: _ProfileActions.viewNotification, @@ -228,32 +230,33 @@ class _ProfilePageState extends State { ), ), PopupMenuItem( - value: .editProfile, + value: _ProfileActions.viewPoints, child: Row( children: [ - const Icon(Symbols.person_edit), + const Icon(Icons.show_chart_outlined), sizedBoxPopupMenuItemIconSpacing, - Text(context.t.editUserProfilePage.title), + Text(tr.statistics.title), ], ), ), + const PopupMenuDivider(height: 8), PopupMenuItem( - value: _ProfileActions.editAvatar, + value: .editProfile, child: Row( children: [ - const Icon(Symbols.familiar_face_and_zone), + const Icon(Symbols.person_edit), sizedBoxPopupMenuItemIconSpacing, - Text(context.t.editAvatarPage.title), + Text(context.t.editUserProfilePage.title), ], ), ), PopupMenuItem( - value: _ProfileActions.viewPoints, + value: _ProfileActions.editAvatar, child: Row( children: [ - const Icon(Icons.show_chart_outlined), + const Icon(Symbols.familiar_face_and_zone), sizedBoxPopupMenuItemIconSpacing, - Text(tr.statistics.title), + Text(context.t.editAvatarPage.title), ], ), ), @@ -287,14 +290,18 @@ class _ProfilePageState extends State { ], ), ), + const PopupMenuDivider(height: 8), PopupMenuItem( enabled: !logout, value: _ProfileActions.logout, child: Row( children: [ - DebounceIcon(icon: const Icon(Icons.logout_outlined), shouldDebounce: logout), + DebounceIcon( + icon: Icon(Icons.logout_outlined, color: colorScheme.error), + shouldDebounce: logout, + ), sizedBoxPopupMenuItemIconSpacing, - Text(tr.logout), + Text(tr.logout, style: TextStyle(color: colorScheme.error)), ], ), ), @@ -334,14 +341,14 @@ class _ProfilePageState extends State { ]; } - // Widget used in flexible space of app bar. - // Contains a blurred background image, bottom background color and - // user avatar. - Widget? flexSpace; - - final Widget avatar = CircleAvatar( - radius: _appBarAvatarHeight / 2 + 3, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerLowest, + // Flexible space of the app bar: a background (the blurred avatar, or a tinted gradient when the user has no + // avatar, which used to leave the expanded bar empty), the page ground under the avatar and the avatar itself. + final Widget avatar = DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surfaceContainerLowest, width: 3), + boxShadow: [BoxShadow(color: colorScheme.shadow.withValues(alpha: 0.12), blurRadius: 8)], + ), child: HeroUserAvatar( username: userProfile.username ?? '', avatarUrl: userProfile.avatarUrl ?? noAvatarUrl, @@ -350,73 +357,74 @@ class _ProfilePageState extends State { minRadius: _appBarAvatarHeight / 2, ), ); - if (userProfile.avatarUrl != null) { - flexSpace = Stack( - // Disable clip, let profile avatar show outside the stack. - clipBehavior: Clip.none, - children: [ - // Background blurred image. - Positioned.fill( - child: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: CachedImageProvider(userProfile.avatarUrl!), - fit: .fitWidth, - isAntiAlias: true, - ), + final background = userProfile.avatarUrl != null + ? Container( + decoration: BoxDecoration( + image: DecorationImage( + image: CachedImageProvider(userProfile.avatarUrl!), + fit: .fitWidth, + isAntiAlias: true, ), - foregroundDecoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.surfaceContainerLowest.withValues(alpha: 0.6), - Theme.of(context).colorScheme.surfaceContainerLowest, - ], - begin: .topCenter, - end: .bottomCenter, - stops: const [0.0, 0.55], - ), + ), + foregroundDecoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.surfaceContainerLowest.withValues(alpha: 0.6), + colorScheme.surfaceContainerLowest, + ], + begin: .topCenter, + end: .bottomCenter, + stops: const [0.0, 0.55], ), ), - ), - // Background color under avatar, height is half of avatar height. - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Row( - children: [ - Expanded( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLowest, - // Add 4 here because the avatar row (Positioned() below) has 4 padding at bottom. - child: const SizedBox(height: _appBarAvatarHeight / 2 + 4), - ), - ), - ], + ) + : DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [colorScheme.primaryContainer, colorScheme.surfaceContainerLowest], + begin: .topCenter, + end: .bottomCenter, + stops: const [0.0, 0.7], + ), ), + ); + final flexSpace = Stack( + // Disable clip, let profile avatar show outside the stack. + clipBehavior: Clip.none, + children: [ + Positioned.fill(child: background), + // Background color under avatar, height is half of avatar height. + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ColoredBox( + color: colorScheme.surfaceContainerLowest, + // Add 4 here because the avatar row (Positioned() below) has 4 padding at bottom. + child: const SizedBox(height: _appBarAvatarHeight / 2 + 4), ), - // Avatar and user info. - Positioned( - bottom: 4, - left: 15, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - avatar, - Tooltip( - message: tr.online, - child: Icon( - Icons.circle, - size: 16, - color: Theme.of(context).brightness == Brightness.dark ? Colors.green[400] : Colors.green[700], - ), + ), + // Avatar, lined up with the centered content below. + Positioned( + bottom: 4, + left: appCenteredPadding(MediaQuery.sizeOf(context).width, maxWidth: appFormMaxWidth, minPadding: 12).left, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + avatar, + Tooltip( + message: tr.online, + child: Icon( + Icons.circle, + size: 16, + color: Theme.of(context).brightness == Brightness.dark ? Colors.green[400] : Colors.green[700], ), - ], - ), + ), + ], ), - ], - ); - } + ), + ], + ); return SliverAppBar( title: _showAppBarTitle ? Text(state.userProfile?.username ?? '') : null, @@ -428,15 +436,18 @@ class _ProfilePageState extends State { ); } - List _buildCheckinInfoRow(BuildContext context, ProfileState state) { + /// Check-in level, progress to the next level and the check-in numbers, one surface. + Widget? _buildCheckinSection(BuildContext context, ProfileState state) { final userProfile = state.userProfile; if (userProfile == null || userProfile.checkinLevel == null || userProfile.checkinDaysCount == null && (!(userProfile.checkinLevel?.contains('Master') ?? false) || userProfile.checkinNextLevelDays == null)) { - return []; + return null; } final tr = context.t.profilePage; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; int? totalDays; final double percent; @@ -475,73 +486,60 @@ class _ProfilePageState extends State { description = '${userProfile.checkinDaysCount}/-'; } - // Checkin - return [ - _SectionTitle(tr.checkin.title), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Checkin level. - Row( + return _ProfileSection( + title: tr.checkin.title, + icon: Icons.event_available_outlined, + children: [ + // Level and progress to the next level. + AppInsetBlock( + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - userProfile.checkinLevel!, - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).primaryColor), + Wrap( + spacing: 12, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + userProfile.checkinLevel!, + style: textTheme.titleSmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold), + ), + Text(description, style: textTheme.labelMedium?.copyWith(color: colorScheme.outline)), + ], ), - sizedBoxW12H12, - Text( - description, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.outline), + sizedBoxW8H8, + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator(value: percent, minHeight: 8), ), ], ), - LinearProgressIndicator(value: percent), - - // General info + ), + _InfoGrid([ if (userProfile.checkinDaysCount != null) - _ProfileSectionListTile( - leading: const Icon(Icons.calendar_month_outlined), - title: Text(tr.checkinDaysCount), - subtitle: Text('${userProfile.checkinDaysCount}'), - ), + (Icons.calendar_month_outlined, tr.checkinDaysCount, '${userProfile.checkinDaysCount}'), if (userProfile.checkinThisMonthCount != null) - _ProfileSectionListTile( - leading: const Icon(Icons.calendar_today_outlined), - title: Text(tr.checkinDaysInThisMonth), - subtitle: Text(userProfile.checkinThisMonthCount!), - ), + (Icons.calendar_today_outlined, tr.checkinDaysInThisMonth, userProfile.checkinThisMonthCount!), if (userProfile.checkinRecentTime != null) - _ProfileSectionListTile( - leading: const Icon(Icons.history_outlined), - title: Text(tr.checkinRecentTime), - subtitle: Text(userProfile.checkinRecentTime!), - ), + (Icons.history_outlined, tr.checkinRecentTime, userProfile.checkinRecentTime!), if (userProfile.checkinAllCoins != null) - _ProfileSectionListTile( - leading: const Icon(FontAwesomeIcons.coins), - title: Text(tr.checkinAllCoins), - subtitle: Text(userProfile.checkinAllCoins!), - ), + (FontAwesomeIcons.coins, tr.checkinAllCoins, userProfile.checkinAllCoins!), if (userProfile.checkinLastTimeCoin != null) - _ProfileSectionListTile( - leading: const Icon(Icons.monetization_on_outlined), - title: Text(tr.checkinLastTimeCoins), - subtitle: Text(userProfile.checkinLastTimeCoin!), - ), + (Icons.monetization_on_outlined, tr.checkinLastTimeCoins, userProfile.checkinLastTimeCoin!), if (userProfile.checkinTodayStatus != null) - _ProfileSectionListTile( - leading: const Icon(Icons.today_outlined), - title: Text(tr.checkinTodayStatus), - subtitle: Text(userProfile.checkinTodayStatus ?? '-'), - ), - ].insertBetween(sizedBoxW4H4), - ), - ]; + (Icons.today_outlined, tr.checkinTodayStatus, userProfile.checkinTodayStatus ?? '-'), + ]), + ], + ); } - List _buildSliverContent(BuildContext context, ProfileState state) { + /// Name, uid, nickname and custom title, verification marks and the personal details, one surface. + Widget _buildIdentitySection(BuildContext context, ProfileState state) { final tr = context.t.profilePage; final userProfile = state.userProfile!; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; // Friends count info. final (count: friendsCount, url: friendsPage) = parseFriendsInfo(userProfile.friendsCount); @@ -553,6 +551,85 @@ class _ProfilePageState extends State { userProfile.birthdayDay, ].whereType().join('.'); + final subtitles = [?userProfile.nickname, ?userProfile.customTitle]; + + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Username (tap to copy) and uid. + Wrap( + spacing: 12, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + GestureDetector( + child: SingleLineText( + userProfile.username ?? context.t.profilePage.title, + style: textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), + ), + onTap: () async => copyToClipboard(context, userProfile.username ?? ''), + ), + if (userProfile.uid != null) AppInfoPill(icon: Icons.tag, label: userProfile.uid!), + ], + ), + if (subtitles.isNotEmpty) ...[ + sizedBoxW4H4, + // Wraps instead of scrolling sideways. + Text(subtitles.join(' · '), style: textTheme.titleSmall?.copyWith(color: colorScheme.outline)), + ], + sizedBoxW12H12, + Wrap( + spacing: 6, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (userProfile.uid != null) + // Email verify state. + IconButton( + icon: const Icon(Icons.email_outlined), + tooltip: userProfile.emailVerified ?? false ? tr.emailVerified : tr.emailNotVerified, + onPressed: () async { + final content = userProfile.emailVerified ?? false ? tr.emailVerified : tr.emailNotVerified; + showSnackBar(context: context, message: content); + }, + isSelected: userProfile.emailVerified ?? false, + ), + IconButton( + icon: const Icon(Icons.photo_camera_outlined), + tooltip: userProfile.videoVerified ?? false ? tr.videoVerified : tr.videoNotVerified, + onPressed: () async { + final content = userProfile.videoVerified ?? false ? tr.videoVerified : tr.videoNotVerified; + showSnackBar(context: context, message: content); + }, + isSelected: userProfile.videoVerified ?? false, + ), + TextButton.icon( + icon: const Icon(Icons.group_outlined), + label: Text(friendsCount), + onPressed: friendsPage != null + ? () async { + await context.dispatchAsUrl(friendsPage); + } + : null, + ), + if (userProfile.gender != null) AppInfoPill(icon: Icons.face_2_outlined, label: userProfile.gender!), + if (birthDayText.isNotEmpty) AppInfoPill(icon: Icons.cake_outlined, label: birthDayText), + if (userProfile.zodiac != null) AppInfoPill(icon: MdiIcons.starCrescent, label: userProfile.zodiac!), + if (userProfile.from != null) AppInfoPill(icon: Icons.location_on_outlined, label: userProfile.from!), + if (userProfile.msn != null) AppInfoPill(icon: Icons.group_outlined, label: userProfile.msn!), + if (userProfile.qq != null) AppInfoPill(icon: FontAwesomeIcons.qq, label: userProfile.qq!), + ], + ), + ], + ), + ); + } + + /// The two group images with their colored names, side by side, each wrapping under the other when narrow. + Widget? _buildUserGroupSection(BuildContext context, ProfileState state) { + final tr = context.t.profilePage; + final userProfile = state.userProfile!; final inDark = Theme.of(context).brightness == Brightness.dark; final moderatorGroupDoc = parseHtmlDocument(userProfile.moderatorGroup ?? '').body; @@ -581,6 +658,44 @@ class _ProfilePageState extends State { userGroupNameColor = null; } + if (moderatorGroupImg == null && userGroupImg == null) { + return null; + } + + Widget group(String image, String name, Color? color) => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CachedImage(image, maxWidth: 200, height: profileBadgeHeight, fit: BoxFit.contain), + sizedBoxW8H8, + Text( + name, + textAlign: TextAlign.start, + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + ], + ); + + return _ProfileSection( + title: tr.userGroup, + icon: Icons.groups_outlined, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + if (moderatorGroupImg != null) group(moderatorGroupImg, moderatorGroupName ?? '', moderatorGroupNameColor), + if (userGroupImg != null) group(userGroupImg, userGroupName ?? '-', userGroupNameColor), + ], + ), + ], + ); + } + + List _buildSliverContent(BuildContext context, ProfileState state) { + final tr = context.t.profilePage; + final userProfile = state.userProfile!; + // Introduction. // // Introduction is captured as raw html code when have multiple lines. @@ -597,302 +712,162 @@ class _ProfilePageState extends State { signatureContent = parseHtmlDocument(userProfile.signature ?? '').body; } - final iconChipBackgroundColor = Theme.of(context).colorScheme.surfaceContainerLowest; + final userGroup = _buildUserGroupSection(context, state); + final checkin = _buildCheckinSection(context, state); - // All content widgets in profile main sliver list. - return [ - // Username and uid - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - sizedBoxW12H12, - GestureDetector( - child: SingleLineText( - userProfile.username ?? context.t.profilePage.title, - style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), - ), - onTap: () async => copyToClipboard(context, userProfile.username ?? ''), - ), - sizedBoxW12H12, - if (userProfile.uid != null) - SingleLineText( - userProfile.uid!, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ], - ), - ), - sizedBoxW12H12, - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - sizedBoxW12H12, - if (userProfile.nickname != null) - SingleLineText( - userProfile.nickname!, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - if (userProfile.nickname != null && userProfile.customTitle != null) - const SizedBox(width: 20, height: 20, child: VerticalDivider()), - if (userProfile.customTitle != null) - SingleLineText( - userProfile.customTitle!, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ], - ), - ), - sizedBoxW12H12, - Wrap( - spacing: 4, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (userProfile.uid != null) - // Email verify state. - IconButton( - icon: const Icon(Icons.email_outlined), - tooltip: userProfile.emailVerified ?? false ? tr.emailVerified : tr.emailNotVerified, - onPressed: () async { - final content = userProfile.emailVerified ?? false ? tr.emailVerified : tr.emailNotVerified; - showSnackBar(context: context, message: content); - }, - isSelected: userProfile.emailVerified ?? false, - ), - IconButton( - icon: const Icon(Icons.photo_camera_outlined), - tooltip: userProfile.videoVerified ?? false ? tr.videoVerified : tr.videoNotVerified, - onPressed: () async { - final content = userProfile.videoVerified ?? false ? tr.videoVerified : tr.videoNotVerified; - showSnackBar(context: context, message: content); - }, - isSelected: userProfile.videoVerified ?? false, - ), - TextButton.icon( - icon: const Icon(Icons.group_outlined), - label: Text(friendsCount), - onPressed: friendsPage != null - ? () async { - await context.dispatchAsUrl(friendsPage); - } - : null, - ), - if (userProfile.gender != null) - IconChip( - iconData: Icons.face_2_outlined, - text: Text(userProfile.gender!), - backgroundColor: iconChipBackgroundColor, - ), - if (birthDayText.isNotEmpty) - IconChip(iconData: Icons.cake_outlined, text: Text(birthDayText), backgroundColor: iconChipBackgroundColor), - if (userProfile.zodiac != null) - IconChip( - iconData: MdiIcons.starCrescent, - text: Text(userProfile.zodiac!), - backgroundColor: iconChipBackgroundColor, - ), - if (userProfile.from != null) - IconChip( - iconData: Icons.location_on_outlined, - text: Text(userProfile.from!), - backgroundColor: iconChipBackgroundColor, - ), - if (userProfile.msn != null) - IconChip( - iconData: Icons.group_outlined, - text: Text(userProfile.msn!), - backgroundColor: iconChipBackgroundColor, - ), - if (userProfile.qq != null) - IconChip( - iconData: FontAwesomeIcons.qq, - text: Text(userProfile.qq!), - iconSize: 14, - backgroundColor: iconChipBackgroundColor, - ), - ], - ), + final activityItems = <_InfoItem>[ + if (userProfile.onlineTime != null) (Icons.timelapse_outlined, tr.onlineTime, userProfile.onlineTime!), + if (userProfile.registerTime != null) + (MdiIcons.timelineAlertOutline, tr.registerTime, userProfile.registerTime!.yyyyMMDDHHMM()), + if (userProfile.lastVisitTime != null) + (MdiIcons.timelineClockOutline, tr.lastVisitTime, userProfile.lastVisitTime!.yyyyMMDDHHMM()), + if (userProfile.lastActiveTime != null) + (MdiIcons.timelineCheckOutline, tr.lastActiveTime, userProfile.lastActiveTime!.yyyyMMDDHHMM()), + if (userProfile.lastPostTime != null) + (MdiIcons.timelinePlusOutline, tr.lastPostTime, userProfile.lastPostTime!.yyyyMMDDHHMM()), + if (userProfile.timezone != null) (Symbols.globe_location_pin, tr.timezone, userProfile.timezone!), + ]; - // Self introduction. - if (introductionContent != null) ...[ - sizedBoxW16H16, - InputDecorator( - decoration: InputDecoration(labelText: tr.introduction, filled: false), - child: munchElement(context, introductionContent), - ), - ], + final statisticsItems = <_InfoItem>[ + if (userProfile.credits != null) (null, tr.statistics.credits, userProfile.credits!), + if (userProfile.famous != null) (null, tr.statistics.famous, userProfile.famous!), + if (userProfile.coins != null) (null, tr.statistics.coins, userProfile.coins!), + if (userProfile.publicity != null) (null, tr.statistics.publicity, userProfile.publicity!), + if (userProfile.natural != null) (null, tr.statistics.natural, userProfile.natural!), + if (userProfile.scheming != null) (null, tr.statistics.scheming, userProfile.scheming!), + if (userProfile.spirit != null) (null, tr.statistics.spirit, userProfile.spirit!), + // Special attr changes over time. + // Here is dynamic and not translated. + if (userProfile.specialAttr != null && userProfile.specialAttrName != null) + (null, userProfile.specialAttrName!, userProfile.specialAttr!), + if (userProfile.specialAttr2 != null && userProfile.specialAttrName2 != null) + (null, userProfile.specialAttrName2!, userProfile.specialAttr2!), + ]; - // Signature. - if (signatureContent != null) ...[ - sizedBoxW16H16, - InputDecorator( - decoration: InputDecoration(labelText: tr.signature, filled: false), - child: munchElement(context, signatureContent), - ), - ], + final sections = [ + _buildIdentitySection(context, state), - // User group - if (moderatorGroupImg != null || userGroupImg != null) ...[ - _SectionTitle(tr.userGroup), - Row( + // Self introduction and signature, rendered as the forum wrote them. + if (introductionContent != null || signatureContent != null) + _ProfileSection( + title: introductionContent != null ? tr.introduction : tr.signature, + icon: Icons.notes_outlined, children: [ - if (moderatorGroupImg != null) - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CachedImage(moderatorGroupImg, maxWidth: 200, maxHeight: _groupAvatarHeight), - sizedBoxW4H4, - Text(moderatorGroupName ?? '', style: TextStyle(color: moderatorGroupNameColor)), - ], - ), - ), - if (moderatorGroupImg != null && userGroupImg != null) - const SizedBox(width: 20, height: _groupAvatarHeight, child: VerticalDivider()), - if (userGroupImg != null) - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CachedImage(userGroupImg, maxWidth: 200, maxHeight: _groupAvatarHeight), - sizedBoxW4H4, - Text(userGroupName ?? '-', style: TextStyle(color: userGroupNameColor)), - ], - ), - ), + if (introductionContent != null) + AppInsetBlock(padding: edgeInsetsL12T12R12B12, child: munchElement(context, introductionContent)), + if (introductionContent != null && signatureContent != null) + AppSectionHeader(tr.signature, icon: Icons.draw_outlined, padding: EdgeInsets.zero), + if (signatureContent != null) + AppInsetBlock(padding: edgeInsetsL12T12R12B12, child: munchElement(context, signatureContent)), ], ), - ], - /// Medals, if any. - if (userProfile.profileMedals?.isNotEmpty ?? false) ...[ - _SectionTitle(tr.medals), - MedalGroupView( - userProfile.profileMedals! - .map((e) => Medal(name: e.name, image: e.image, alter: e.alter, description: e.description)) - .toList(), - ), - ], + ?userGroup, - // Medal centre entry: the catalogue is meant for the logged user (buy/apply for themselves), so it only shows - // on the user's own profile, like the achievements entry in the app bar. - if (widget.username == null && widget.uid == null) - ListTile( - leading: const Icon(Icons.workspace_premium_outlined), - title: Text(context.t.medalCenter.title), - trailing: const Icon(Icons.chevron_right), - onTap: () async => context.pushNamed(ScreenPaths.medalCenter), + // Secondary title of the profile owner. + _ProfileSecondaryTitle(parsedUrl: state.secondaryTitleUrl, profileUid: int.tryParse(userProfile.uid ?? '')), + + /// Medals, if any. + if (userProfile.profileMedals?.isNotEmpty ?? false) + _ProfileSection( + title: tr.medals, + icon: Icons.military_tech_outlined, + children: [ + MedalGroupView( + userProfile.profileMedals! + .map((e) => Medal(name: e.name, image: e.image, alter: e.alter, description: e.description)) + .toList(), + ), + ], ), + // Medal centre, own titles and title shop: all meant for the logged user (buy/apply/switch for themselves), so + // the entry only shows on the user's own profile, like the achievements entry in the app bar. if (widget.username == null && widget.uid == null) - ListTile( - leading: const Icon(Icons.account_balance_outlined), - title: Text(context.t.bank.title), - trailing: const Icon(Icons.chevron_right), - onTap: () async => context.pushNamed(ScreenPaths.bank), + AppSurface( + padding: EdgeInsets.zero, + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.workspace_premium_outlined), + title: Text(context.t.medalTitleHub.title), + subtitle: Text(context.t.medalTitleHub.entryDescription), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.medalTitleHub), + ), + const Divider(height: 1, indent: 16, endIndent: 16), + ListTile( + leading: const Icon(Icons.account_balance_outlined), + title: Text(context.t.bank.title), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.bank), + ), + ], + ), ), - if (userProfile.mangedForums?.isNotEmpty ?? false) ...[ - _SectionTitle(tr.mangedForum), - Wrap( - spacing: 4, - runSpacing: 4, - children: userProfile.mangedForums! - .map( - (e) => ActionChip( - visualDensity: VisualDensity.compact, - label: Text(e.name), - onPressed: () async => context.pushNamed(ScreenPaths.forum, pathParameters: {'fid': '${e.fid}'}), - ), - ) - .toList(), + if (userProfile.mangedForums?.isNotEmpty ?? false) + _ProfileSection( + title: tr.mangedForum, + icon: Icons.admin_panel_settings_outlined, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: userProfile.mangedForums! + .map( + (e) => ActionChip( + visualDensity: VisualDensity.compact, + label: Text(e.name), + onPressed: () async => context.pushNamed(ScreenPaths.forum, pathParameters: {'fid': '${e.fid}'}), + ), + ) + .toList(), + ), + ], ), - ], /// Checkin level. - ..._buildCheckinInfoRow(context, state), + ?checkin, /// Activity - _SectionTitle(tr.activityStatus), - if (userProfile.onlineTime != null) - _ProfileSectionListTile( - leading: const Icon(Icons.timelapse_outlined), - title: Text(tr.onlineTime), - subtitle: Text(userProfile.onlineTime!), - ), - if (userProfile.registerTime != null) - _ProfileSectionListTile( - leading: Icon(MdiIcons.timelineAlertOutline), - title: Text(tr.registerTime), - subtitle: Text(userProfile.registerTime!.yyyyMMDDHHMM()), - ), - if (userProfile.lastVisitTime != null) - _ProfileSectionListTile( - leading: Icon(MdiIcons.timelineClockOutline), - title: Text(tr.lastVisitTime), - subtitle: Text(userProfile.lastVisitTime!.yyyyMMDDHHMM()), - ), - if (userProfile.lastActiveTime != null) - _ProfileSectionListTile( - leading: Icon(MdiIcons.timelineCheckOutline), - title: Text(tr.lastActiveTime), - subtitle: Text(userProfile.lastActiveTime!.yyyyMMDDHHMM()), - ), - if (userProfile.lastPostTime != null) - _ProfileSectionListTile( - leading: Icon(MdiIcons.timelinePlusOutline), - title: Text(tr.lastPostTime), - subtitle: Text(userProfile.lastPostTime!.yyyyMMDDHHMM()), - ), - if (userProfile.timezone != null) - _ProfileSectionListTile( - leading: const Icon(Symbols.globe_location_pin), - title: Text(tr.timezone), - subtitle: Text(userProfile.timezone!), - ), - if (userProfile.registerIP != null) - ObscureListTile( - contentPadding: EdgeInsets.zero, - minTileHeight: 0, - leading: const Icon(Symbols.add_location_alt), - title: Text(tr.registerIP), - subtitle: Text(userProfile.registerIP!), - ), - if (userProfile.lastVisitIP != null) - ObscureListTile( - contentPadding: EdgeInsets.zero, - minTileHeight: 0, - leading: const Icon(Symbols.moved_location), - title: Text(tr.lastVisitIP), - subtitle: Text(userProfile.lastVisitIP!), - ), - - /// Statistics. - _SectionTitle(tr.statistics.title), - GridView( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3, mainAxisExtent: 70), - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), + _ProfileSection( + title: tr.activityStatus, + icon: Icons.timeline_outlined, children: [ - if (userProfile.credits != null) AttrBlock(name: tr.statistics.credits, value: userProfile.credits!), - if (userProfile.famous != null) AttrBlock(name: tr.statistics.famous, value: userProfile.famous!), - if (userProfile.coins != null) AttrBlock(name: tr.statistics.coins, value: userProfile.coins!), - if (userProfile.publicity != null) AttrBlock(name: tr.statistics.publicity, value: userProfile.publicity!), - if (userProfile.natural != null) AttrBlock(name: tr.statistics.natural, value: userProfile.natural!), - if (userProfile.scheming != null) AttrBlock(name: tr.statistics.scheming, value: userProfile.scheming!), - if (userProfile.spirit != null) AttrBlock(name: tr.statistics.spirit, value: userProfile.spirit!), - // Special attr changes over time. - // Here is dynamic and not translated. - if (userProfile.specialAttr != null && userProfile.specialAttrName != null) - AttrBlock(name: userProfile.specialAttrName!, value: userProfile.specialAttr!), - if (userProfile.specialAttr2 != null && userProfile.specialAttrName2 != null) - AttrBlock(name: userProfile.specialAttrName2!, value: userProfile.specialAttr2!), + if (activityItems.isNotEmpty) _InfoGrid(activityItems), + // IP addresses stay hidden until asked for. + if (userProfile.registerIP != null) + ObscureListTile( + contentPadding: EdgeInsets.zero, + minTileHeight: 0, + leading: const Icon(Symbols.add_location_alt), + title: Text(tr.registerIP), + subtitle: Text(userProfile.registerIP!), + ), + if (userProfile.lastVisitIP != null) + ObscureListTile( + contentPadding: EdgeInsets.zero, + minTileHeight: 0, + leading: const Icon(Symbols.moved_location), + title: Text(tr.lastVisitIP), + subtitle: Text(userProfile.lastVisitIP!), + ), ], ), + + /// Statistics: value blocks sized by their text, not a fixed 70 pixels grid cut at large text scales. + if (statisticsItems.isNotEmpty) + _ProfileSection( + title: tr.statistics.title, + icon: Icons.bar_chart_outlined, + children: [_InfoGrid(statisticsItems, minTileWidth: 110, maxColumns: 4, emphasizeValue: true)], + ), + ]; + + // All content widgets in profile main sliver list. + return [ + for (var i = 0; i < sections.length; i++) ...[if (i > 0) const SizedBox(height: appSurfaceGap), sections[i]], ]; } @@ -913,8 +888,15 @@ class _ProfilePageState extends State { controller: _refreshController, scrollController: _scrollController, header: const MaterialHeader(), - onRefresh: () => - context.read().add(ProfileRefreshRequested(uid: widget.uid, username: widget.username)), + onRefresh: () { + context.read().add(ProfileRefreshRequested(uid: widget.uid, username: widget.username)); + // The own title comes from the titles page of the account, refresh it with the profile. + final currentTitle = context.readOrNull(); + final currentUid = context.readOrNull()?.currentUser?.uid; + if (currentTitle != null && currentUid != null && '$currentUid' == state.userProfile?.uid) { + unawaited(currentTitle.ensureLoaded(force: true)); + } + }, childBuilder: (context, physics) => CustomScrollView( controller: _scrollController, physics: physics, @@ -922,7 +904,12 @@ class _ProfilePageState extends State { // Real app bar when data loaded. _buildSliverAppBar(context, state, logout: logout), SliverPadding( - padding: edgeInsetsL12T4R12, + // Centered and at most [appFormMaxWidth] wide on wide windows, room for the navigation bar at the end. + padding: appCenteredPadding( + MediaQuery.sizeOf(context).width, + maxWidth: appFormMaxWidth, + minPadding: 12, + ).copyWith(top: 12, bottom: 24 + MediaQuery.paddingOf(context).bottom), sliver: SliverList(delegate: SliverChildListDelegate(_buildSliverContent(context, state))), ), ], @@ -997,48 +984,188 @@ class _ProfilePageState extends State { } } -class _SectionTitle extends StatelessWidget { - const _SectionTitle(this.title); +/// Secondary title of the profile owner. +/// +/// * The image the profile page itself renders for its owner, when it has one ([parseProfileSecondaryTitleUrl]). +/// * Otherwise, on the profile of the logged in account only, the title read from that account's titles page +/// ([CurrentTitleCubit]). Other users never get the current account's title. +class _ProfileSecondaryTitle extends StatefulWidget { + const _ProfileSecondaryTitle({required this.parsedUrl, required this.profileUid}); + + /// Title image found in the profile page. + final String? parsedUrl; - final String title; + /// Uid of the profile owner, as the profile page states it. + final int? profileUid; + + @override + State<_ProfileSecondaryTitle> createState() => _ProfileSecondaryTitleState(); +} + +class _ProfileSecondaryTitleState extends State<_ProfileSecondaryTitle> { + CurrentTitleCubit? _cubit; + + /// The profile shown is the logged in account's. + bool get _isOwn { + final uid = widget.profileUid; + return uid != null && uid == context.readOrNull()?.currentUser?.uid; + } + + @override + void initState() { + super.initState(); + _cubit = context.readOrNull(); + final cubit = _cubit; + if (widget.parsedUrl == null && cubit != null && _isOwn) { + unawaited(cubit.ensureLoaded()); + } + } + + Widget _section(BuildContext context, Widget badge) => _ProfileSection( + title: context.t.profilePage.secondaryTitle, + icon: Icons.badge_outlined, + children: [ + Align(alignment: AlignmentDirectional.centerStart, child: badge), + ], + ); @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sizedBoxW24H24, - Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary), - ), - sizedBoxW12H12, - ], + // Match the group badge height while leaving room for the page and surface paddings. + final width = SecondaryTitleBadge.fitWidth( + math.min(MediaQuery.sizeOf(context).width, appFormMaxWidth) - 56, + preferred: SecondaryTitleBadge.widthFor(profileBadgeHeight), + ); + final parsedUrl = widget.parsedUrl; + if (parsedUrl != null) { + return _section(context, SecondaryTitleBadge(parsedUrl, width: width)); + } + final cubit = _cubit; + if (cubit == null || !_isOwn) { + return sizedBoxEmpty; + } + return BlocBuilder( + bloc: cubit, + builder: (context, state) { + final url = state.imageUrlFor(widget.profileUid); + if (url != null) { + return _section( + context, + SecondaryTitleBadge(url, key: ValueKey(url), width: width, semanticLabel: state.title?.name), + ); + } + return switch (state.statusFor(widget.profileUid)) { + CurrentTitleStatus.loading => _section(context, SecondaryTitlePlaceholder(width: width)), + // Reading failed: say so and offer to try again, never claim that no title is in use. + CurrentTitleStatus.failure => _section( + context, + SecondaryTitleRetry(width: width, onRetry: () => unawaited(cubit.ensureLoaded())), + ), + CurrentTitleStatus.initial || CurrentTitleStatus.success => sizedBoxEmpty, + }; + }, ); } } -class _ProfileSectionListTile extends StatelessWidget { - /// Constructor. - const _ProfileSectionListTile({required this.leading, required this.title, required this.subtitle}); +/// A section of the profile: a surface with a header and its content stacked with a small gap. +class _ProfileSection extends StatelessWidget { + const _ProfileSection({required this.title, required this.icon, required this.children}); - /// [ListTile.leading]. - final Widget? leading; + final String title; - /// [ListTile.title]. - final Widget? title; + final IconData icon; - /// [ListTile.subtitle]. - final Widget? subtitle; + final List children; @override - Widget build(BuildContext context) { - return ListTile( - minTileHeight: 0, - contentPadding: EdgeInsets.zero, - leading: leading, - title: title, - subtitle: subtitle, + Widget build(BuildContext context) => AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + AppSectionHeader(title, icon: icon, padding: const EdgeInsets.only(bottom: 8)), + for (var i = 0; i < children.length; i++) ...[if (i > 0) sizedBoxW8H8, children[i]], + ], + ), + ); +} + +/// Optional icon, caption and value of a profile number or date. +typedef _InfoItem = (IconData? icon, String label, String value); + +/// Value blocks laid out in as many columns as fit the room at the current text scale (at most [maxColumns]); the +/// blocks of a row share its height, nothing is cut. +class _InfoGrid extends StatelessWidget { + const _InfoGrid(this.items, {this.minTileWidth = 160, this.maxColumns = 3, this.emphasizeValue = false}); + + final List<_InfoItem> items; + + /// Narrowest a block gets at text scale 1. + final double minTileWidth; + + final int maxColumns; + + /// Show the value larger than the caption (numbers). + final bool emphasizeValue; + + Widget _tile(BuildContext context, _InfoItem item) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + final (icon, label, value) = item; + return AppInsetBlock( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[Icon(icon, size: 18, color: colorScheme.primary), sizedBoxW8H8], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: textTheme.labelSmall?.copyWith(color: colorScheme.outline)), + Text( + value, + style: (emphasizeValue ? textTheme.titleMedium : textTheme.bodyMedium)?.copyWith( + fontWeight: emphasizeValue ? FontWeight.bold : null, + ), + ), + ], + ), + ), + ], + ), ); } + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final scale = MediaQuery.textScalerOf(context).scale(14) / 14; + final columns = (constraints.maxWidth / (minTileWidth * scale)).floor().clamp(1, maxColumns); + final rows = appRowCount(items.length, columns); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var row = 0; row < rows; row++) ...[ + if (row > 0) sizedBoxW8H8, + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var column = 0; column < columns; column++) ...[ + if (column > 0) sizedBoxW8H8, + Expanded( + child: row * columns + column < items.length + ? _tile(context, items[row * columns + column]) + : sizedBoxEmpty, + ), + ], + ], + ), + ), + ], + ], + ); + }, + ); } diff --git a/lib/features/profile/view/switch_user_group_page.dart b/lib/features/profile/view/switch_user_group_page.dart index 295308233..6c283b37e 100644 --- a/lib/features/profile/view/switch_user_group_page.dart +++ b/lib/features/profile/view/switch_user_group_page.dart @@ -10,6 +10,7 @@ import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/section_list_tile.dart'; import 'package:tsdm_client/widgets/section_title_text.dart'; @@ -29,42 +30,56 @@ class _SwitchUserGroupPageState extends State with LoggerMi final colorScheme = Theme.of(context).colorScheme; final bodyTheme = Theme.of(context).textTheme.bodyMedium; - return ListView( - children: [ - SectionTitleText(tr.currentGroup), - SectionListTile( - title: Text(state.currentUserGroup, style: bodyTheme?.copyWith(color: colorScheme.secondary)), - ), - if (state.status == SwitchUserGroupStatus.switching) - Row(children: [SectionTitleText(tr.availableGroups), sizedCircularProgressIndicator]) - else - SectionTitleText(tr.availableGroups), - if (state.availableGroups.isEmpty) - SectionListTile( - title: Text(tr.nonAvailable, style: bodyTheme?.copyWith(color: colorScheme.outline)), - ) - else - ...state.availableGroups.map( - (e) => SectionListTile( - title: Text(e.name), - subtitle: Text('GID: ${e.gid}'), - enabled: state.status != SwitchUserGroupStatus.switching, - onTap: () async { - final confirmed = await showQuestionDialog( - context: context, - title: tr.title, - message: tr.confirmMessage(from: state.currentUserGroup, to: e.name), - ); - if (confirmed != true || !context.mounted) { - return; - } - context.read().add( - SwitchUserGroupRunSwitchRequested(e.name, e.gid, state.formHash), - ); - }, + return AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, side, _) => ListView( + padding: side.copyWith(top: 8, bottom: 24), + children: [ + SectionTitleText(tr.currentGroup), + AppSurface( + padding: EdgeInsets.zero, + child: SectionListTile( + title: Text(state.currentUserGroup, style: bodyTheme?.copyWith(color: colorScheme.secondary)), ), ), - ], + if (state.status == SwitchUserGroupStatus.switching) + Row(children: [SectionTitleText(tr.availableGroups), sizedCircularProgressIndicator]) + else + SectionTitleText(tr.availableGroups), + AppSurface( + padding: EdgeInsets.zero, + child: Column( + children: [ + if (state.availableGroups.isEmpty) + SectionListTile( + title: Text(tr.nonAvailable, style: bodyTheme?.copyWith(color: colorScheme.outline)), + ) + else + ...state.availableGroups.map( + (e) => SectionListTile( + title: Text(e.name), + subtitle: Text('GID: ${e.gid}'), + enabled: state.status != SwitchUserGroupStatus.switching, + onTap: () async { + final confirmed = await showQuestionDialog( + context: context, + title: tr.title, + message: tr.confirmMessage(from: state.currentUserGroup, to: e.name), + ); + if (confirmed != true || !context.mounted) { + return; + } + context.read().add( + SwitchUserGroupRunSwitchRequested(e.name, e.gid, state.formHash), + ); + }, + ), + ), + ], + ), + ), + ], + ), ); } diff --git a/lib/features/profile/widgets/secondary_title_badge.dart b/lib/features/profile/widgets/secondary_title_badge.dart new file mode 100644 index 000000000..721f9691e --- /dev/null +++ b/lib/features/profile/widgets/secondary_title_badge.dart @@ -0,0 +1,232 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/constants/constants.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; + +/// Shared height of badges beside a post author, independent of orientation. +const authorBadgeHeight = 32.0; + +/// Shared height of badges in profile pages and dialogs. +const profileBadgeHeight = 64.0; + +/// Image of a secondary title (the forum's second badge, natively [badgeImageSize]). +/// +/// The image is contained in a box of [width] with the natural aspect ratio, so it is never cropped nor stretched and +/// takes a predictable room; loading and broken images get the usual [CachedImage] placeholders. +class SecondaryTitleBadge extends StatelessWidget { + /// Constructor. + const SecondaryTitleBadge(this.imageUrl, {this.width = 92, this.semanticLabel, super.key}); + + /// Image url, as rendered by the forum. + final String imageUrl; + + /// Width of the box, the height follows the natural aspect ratio. + final double width; + + /// Title name for screen readers, when known. + final String? semanticLabel; + + /// Height of a badge [width] wide. + static double heightFor(double width) => width * badgeImageSize.height / badgeImageSize.width; + + /// Width preserving the title aspect ratio at the requested display height. + static double widthFor(double height) => height * badgeImageSize.width / badgeImageSize.height; + + /// Width of a badge that should be about [preferred] wide in [available] room: never wider than the room, nor than + /// the natural width of the image (a larger box would only upscale it). + static double fitWidth(double available, {double preferred = 184}) => + math.max(0, math.min(math.min(available, preferred), badgeImageSize.width)); + + @override + Widget build(BuildContext context) { + final height = heightFor(width); + return Semantics( + image: true, + label: semanticLabel, + child: SizedBox( + width: width, + height: height, + child: CachedImage(imageUrl, width: width, height: height, fit: BoxFit.contain), + ), + ); + } +} + +/// Room of a secondary title of [width] that is not known yet: same size as the badge, so nothing jumps once it is. +class SecondaryTitlePlaceholder extends StatelessWidget { + /// Constructor. + const SecondaryTitlePlaceholder({required this.width, super.key}); + + /// Width of the badge to come. + final double width; + + @override + Widget build(BuildContext context) => SizedBox( + width: width, + height: SecondaryTitleBadge.heightFor(width), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + ), + ), + ); +} + +/// A secondary title that could not be read: the badge room with a retry action. +/// +/// It says the title failed to load, never that no title is in use. +class SecondaryTitleRetry extends StatelessWidget { + /// Constructor. + const SecondaryTitleRetry({required this.width, required this.onRetry, super.key}); + + /// Width of the badge room. + final double width; + + /// Read the title again. + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final message = '${context.t.profilePage.secondaryTitle} · ${context.t.general.failedToLoad}'; + return Tooltip( + message: message, + child: Semantics( + button: true, + label: '$message, ${context.t.general.retry}', + excludeSemantics: true, + child: SizedBox( + width: width, + // Room for the icon and a line of text even when the badge is small. + height: math.max(SecondaryTitleBadge.heightFor(width), 48), + child: Material( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onRetry, + child: Padding( + padding: edgeInsetsL4R4, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.refresh_outlined, size: 20, color: colorScheme.primary), + sizedBoxW2H2, + Text( + context.t.general.retry, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelMedium?.copyWith(color: colorScheme.primary), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Secondary title badge of the logged in account whose uid is [uid], from [CurrentTitleCubit]. +/// +/// Shows nothing when the cubit is not provided, the known title belongs to another account, or the account uses no +/// title. While the title is being read the badge room is kept ([SecondaryTitlePlaceholder]); when reading failed a +/// retry action takes it ([SecondaryTitleRetry]) instead of pretending no title is used. Asks the cubit to read the +/// title once when [load] is set. [padding] only applies when something is shown. +class CurrentAccountTitleBadge extends StatefulWidget { + /// Constructor. + const CurrentAccountTitleBadge({ + required this.uid, + this.width = 92, + this.load = true, + this.padding = EdgeInsets.zero, + super.key, + }); + + /// Uid of the account shown next to the badge; the badge only appears when it is the current account. + final int? uid; + + /// Width of the badge. + final double width; + + /// Request the title when it is not known yet. + final bool load; + + /// Space around the badge, only when it is shown. + final EdgeInsetsGeometry padding; + + @override + State createState() => _CurrentAccountTitleBadgeState(); +} + +class _CurrentAccountTitleBadgeState extends State { + CurrentTitleCubit? _cubit; + + void _requestLoad() { + final cubit = _cubit; + if (widget.load && widget.uid != null && cubit != null) { + unawaited(cubit.ensureLoaded()); + } + } + + @override + void initState() { + super.initState(); + _cubit = context.readOrNull(); + _requestLoad(); + } + + @override + void didUpdateWidget(CurrentAccountTitleBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.uid != widget.uid) { + _requestLoad(); + } + } + + @override + Widget build(BuildContext context) { + final cubit = _cubit; + if (cubit == null) { + return sizedBoxEmpty; + } + return BlocBuilder( + bloc: cubit, + builder: (context, state) { + final url = state.imageUrlFor(widget.uid); + final Widget child; + if (url != null) { + child = SecondaryTitleBadge( + url, + // Keyed by url: switching titles must not keep the frame of the previous image. + key: ValueKey(url), + width: widget.width, + semanticLabel: state.title?.name, + ); + } else { + switch (state.statusFor(widget.uid)) { + case CurrentTitleStatus.loading: + child = SecondaryTitlePlaceholder(width: widget.width); + case CurrentTitleStatus.failure: + child = SecondaryTitleRetry(width: widget.width, onRetry: () => unawaited(cubit.ensureLoaded())); + case CurrentTitleStatus.initial || CurrentTitleStatus.success: + // Not requested for this account, or the account uses no title. + return sizedBoxEmpty; + } + } + return Padding(padding: widget.padding, child: child); + }, + ); + } +} diff --git a/lib/features/profile/widgets/secondary_title_card.dart b/lib/features/profile/widgets/secondary_title_card.dart index dd1b03a7a..80296cbdd 100644 --- a/lib/features/profile/widgets/secondary_title_card.dart +++ b/lib/features/profile/widgets/secondary_title_card.dart @@ -1,83 +1,94 @@ +import 'dart:math' as math; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/features/profile/bloc/my_titles_cubit.dart'; import 'package:tsdm_client/features/profile/models/secondary_title.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; -import 'package:tsdm_client/widgets/network_indicator_image.dart'; -/// Card to show a secondary title. -class SecondaryTitleCard extends StatefulWidget { +/// Card to show a secondary title, tap to use it. +/// +/// The image keeps its natural 184x100 ratio inside the card padding (at most its natural width), the current title is +/// marked with an outline and a label below the name instead of a corner ribbon covering the image. +class SecondaryTitleCard extends StatelessWidget { /// Constructor. const SecondaryTitleCard(this.title, {super.key}); /// The title. final SecondaryTitle title; - @override - State createState() => _SecondaryTitleCardState(); -} - -class _SecondaryTitleCardState extends State { - bool _activated = false; - - @override - void initState() { - super.initState(); - _activated = widget.title.activated; - } - @override Widget build(BuildContext context) { - _activated = context.select( - (cubit) => cubit.state.titles.firstWhereOrNull((e) => e.id == widget.title.id)?.activated ?? false, + final activated = context.select( + (cubit) => cubit.state.titles.firstWhereOrNull((e) => e.id == title.id)?.activated ?? false, ); final loading = context.select((cubit) => cubit.state.status == MyTitlesStatus.switchingTitle); + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; - final card = Card( - clipBehavior: Clip.hardEdge, - color: loading ? Theme.of(context).colorScheme.surfaceContainer : null, + return Card( + clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, + color: activated ? colorScheme.secondaryContainer : colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: activated ? colorScheme.primary : colorScheme.outlineVariant, + width: activated ? 2 : 1, + ), + ), child: InkWell( - onTap: loading ? null : () async => context.read().setSecondaryTitle(widget.title.id), + onTap: loading || activated ? null : () async => context.read().setSecondaryTitle(title.id), child: Padding( padding: edgeInsetsL12T12R12B12, - child: Column( - children: [ - NetworkIndicatorImage(widget.title.imageUrl), - sizedBoxW8H8, - Text( - style: TextStyle( - color: loading - ? Colors.grey[400] - : _activated - ? Theme.of(context).colorScheme.primary - : null, - fontSize: Theme.of(context).textTheme.labelLarge?.fontSize, - ), - '${widget.title.name} (${widget.title.id})', - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final badgeWidth = math.min(constraints.maxWidth, badgeImageSize.width); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SecondaryTitleBadge(title.imageUrl, width: badgeWidth, semanticLabel: title.name), + sizedBoxW8H8, + Text( + title.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.labelLarge?.copyWith( + color: loading + ? colorScheme.outline + : activated + ? colorScheme.onSecondaryContainer + : null, + fontWeight: activated ? FontWeight.bold : null, + ), + ), + sizedBoxW4H4, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (activated) ...[ + Icon(Icons.check_circle, size: 16, color: colorScheme.primary), + sizedBoxW4H4, + Text( + context.t.myTitlesPage.current, + style: textTheme.labelMedium?.copyWith(color: colorScheme.primary), + ), + sizedBoxW8H8, + ], + Text('ID ${title.id}', style: textTheme.labelSmall?.copyWith(color: colorScheme.outline)), + ], + ), + ], + ); + }, ), ), ), ); - - return ConstrainedBox( - // A secondary title image is 184x100. Set a larger size for titles and spaces. - constraints: const BoxConstraints( - minHeight: 130, - ), - child: _activated - ? ClipRect( - child: Banner( - message: context.t.myTitlesPage.current, - location: BannerLocation.topEnd, - child: card, - ), - ) - : card, - ); } } diff --git a/lib/features/profile/widgets/select_birthday_dialog.dart b/lib/features/profile/widgets/select_birthday_dialog.dart index fc82cafb5..53f75bd4e 100644 --- a/lib/features/profile/widgets/select_birthday_dialog.dart +++ b/lib/features/profile/widgets/select_birthday_dialog.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/features/profile/models/birthday_info.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:wheel_choice/wheel_choice.dart'; @@ -122,32 +124,54 @@ class _SelectBirthdayDialogState extends State<_SelectBirthdayDialog> { @override Widget build(BuildContext context) { final tr = context.t.editUserProfilePage.birthday; + final colorScheme = Theme.of(context).colorScheme; return ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith(dragDevices: {.touch, .mouse, .stylus, .trackpad}), child: CustomAlertDialog.sync( clipBehavior: .hardEdge, contentPadding: .zero, - title: Text(tr.title), - content: Row( - children: [(yearController, tr.year), (monthController, tr.month), (dayController, tr.day)] - .map( - (v) => Expanded( - child: WheelChoice<_BirthdayComponent>.raw( - controller: v.$1, - header: WheelHeader(child: Text(v.$2)), - overlay: WheelOverlay.outlined(inset: 12), - effect: const WheelEffect(useMagnifier: true, magnification: 1.1), + title: AppDialogTitle(icon: Icons.cake_outlined, title: tr.title), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The chosen date in words, above the three wheels. + Padding( + padding: edgeInsetsL24R24, + child: AppInsetBlock( + outlined: true, + child: Center( + child: Text( + '${date.year ?? "-"} ${tr.year} ${date.month ?? "-"} ${tr.month} ${date.day ?? "-"} ${tr.day}', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold), ), ), - ) - .toList(), + ), + ), + sizedBoxW8H8, + Row( + children: [(yearController, tr.year), (monthController, tr.month), (dayController, tr.day)] + .map( + (v) => Expanded( + child: WheelChoice<_BirthdayComponent>.raw( + controller: v.$1, + header: WheelHeader(child: Text(v.$2)), + overlay: WheelOverlay.outlined(inset: 12), + effect: const WheelEffect(useMagnifier: true, magnification: 1.1), + ), + ), + ) + .toList(), + ), + ], ), actions: [ TextButton( onPressed: () => context.pop(), child: Text(context.t.general.cancel), ), - TextButton( + FilledButton( onPressed: () => context.pop(date), child: Text(context.t.general.ok), ), diff --git a/lib/features/rate/view/fast_rate_edit_template_page.dart b/lib/features/rate/view/fast_rate_edit_template_page.dart index 44b0cccf9..935e58f54 100644 --- a/lib/features/rate/view/fast_rate_edit_template_page.dart +++ b/lib/features/rate/view/fast_rate_edit_template_page.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; -import 'package:tsdm_client/constants/layout.dart'; -import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/extensions/fp.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Type of editing the fast rate template. @@ -99,7 +98,7 @@ class _FastRateTemplateEditPageState extends State wit builder: (context, snapshot) { if (snapshot.hasError) { error('failed to load all fast rate templates: ${snapshot.error}'); - return Center(child: Text(context.t.general.failedToLoad)); + return AppStateView(error: true, icon: Icons.error_outline, message: context.t.general.failedToLoad); } if (!snapshot.hasData) { @@ -109,131 +108,126 @@ class _FastRateTemplateEditPageState extends State wit final result = snapshot.data!; if (result.isLeft()) { error('failed to unpack fast rate all templates result: ${result.unwrapErr()}'); - return Center(child: Text(context.t.general.failedToLoad)); + return AppStateView(error: true, icon: Icons.error_outline, message: context.t.general.failedToLoad); } final allTemplates = result.unwrap(); + Widget score(TextEditingController controller, String label) => TextFormField( + controller: controller, + decoration: InputDecoration(labelText: label), + keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), + inputFormatters: [numberInputFormatter], + validator: (v) => attributeValidator(v, context), + ); + + final scoreFields = [ + score(editingControllerWw, tr.ww), + score(editingControllerTsb, tr.tsb), + score(editingControllerXc, tr.xc), + score(editingControllerTr, tr.tr), + score(editingControllerFh, tr.fh), + score(editingControllerJl, tr.jl), + score(editingControllerSpecial, tr.special), + score(editingControllerSpecial2, tr.special2), + ]; + return Form( key: _formKey, - child: ListView( - padding: edgeInsetsL12T4R12.add(context.safePadding()), + child: Column( children: [ - TextFormField( - controller: editingControllerName, - autofocus: widget.editType == FastRateTemplateEditType.create, - decoration: InputDecoration(labelText: tr.name), - validator: (v) { - if (v == null || v.isEmpty) { - return tr.editPage.nameNotEmpty; - } - - // Duplicate check. - if (widget.editType == FastRateTemplateEditType.create && !allowOverride) { - // Uid equality is ignored here. - if (allTemplates.any((e) => e.name == v)) { - return tr.editPageAlreadyExists; + Expanded( + child: AppCenteredList( + maxWidth: appFormMaxWidth, + // Not a lazy list: every field stays built, so the form validates all of them. + builder: (context, horizontal, width) => SingleChildScrollView( + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppFormSection( + children: [ + TextFormField( + controller: editingControllerName, + autofocus: widget.editType == FastRateTemplateEditType.create, + decoration: InputDecoration(labelText: tr.name), + validator: (v) { + if (v == null || v.isEmpty) { + return tr.editPage.nameNotEmpty; + } + + // Duplicate check. + if (widget.editType == FastRateTemplateEditType.create && !allowOverride) { + // Uid equality is ignored here. + if (allTemplates.any((e) => e.name == v)) { + return tr.editPageAlreadyExists; + } + } + + return null; + }, + ), + // Only show override option if drafting new templates. + if (widget.editType == FastRateTemplateEditType.create) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(tr.editPageOverride), + value: allowOverride, + onChanged: (v) => setState(() => allowOverride = v), + ), + ], + ), + appListSeparator, + AppFormSection( + title: context.t.ratePostPage.title, + icon: Icons.star_rate_outlined, + children: [ + LayoutBuilder( + builder: (context, constraints) { + // Two fields per row on phones, four once there is room; large text gets fewer. + final fit = (constraints.maxWidth / MediaQuery.textScalerOf(context).scale(150)) + .floor(); + final perRow = fit < 1 ? 1 : (fit > 4 ? 4 : fit); + final fieldWidth = (constraints.maxWidth - 12 * (perRow - 1)) / perRow; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final field in scoreFields) SizedBox(width: fieldWidth, child: field), + ], + ); + }, + ), + ], + ), + ], + ), + ), + ), + ), + AppBottomActionBar( + child: FilledButton( + child: Text(context.t.general.ok), + onPressed: () { + if (!_formKey.currentState!.validate()) { + return; } - } - return null; - }, - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerWw, - decoration: InputDecoration(labelText: tr.ww), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerTsb, - decoration: InputDecoration(labelText: tr.tsb), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerXc, - decoration: InputDecoration(labelText: tr.xc), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerTr, - decoration: InputDecoration(labelText: tr.tr), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerFh, - decoration: InputDecoration(labelText: tr.fh), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerJl, - decoration: InputDecoration(labelText: tr.jl), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerSpecial, - decoration: InputDecoration(labelText: tr.special), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - sizedBoxW8H8, - TextFormField( - controller: editingControllerSpecial2, - decoration: InputDecoration(labelText: tr.special2), - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - inputFormatters: [numberInputFormatter], - validator: (v) => attributeValidator(v, context), - ), - if (widget.editType == FastRateTemplateEditType.create) ...[ - // Only show override option if drafting new templates. - sizedBoxW8H8, - SwitchListTile( - title: Text(tr.editPageOverride), - value: allowOverride, - onChanged: (v) => setState(() => allowOverride = v), + context.pop( + FastRateTemplateModel( + name: editingControllerName.text, + ww: int.parse(editingControllerWw.text), + tsb: int.parse(editingControllerTsb.text), + xc: int.parse(editingControllerXc.text), + tr: int.parse(editingControllerTr.text), + fh: int.parse(editingControllerFh.text), + jl: int.parse(editingControllerJl.text), + special: int.parse(editingControllerSpecial.text), + special2: int.parse(editingControllerSpecial2.text), + ), + ); + }, ), - ], - sizedBoxW8H8, - FilledButton( - child: Text(context.t.general.ok), - onPressed: () { - if (!_formKey.currentState!.validate()) { - return; - } - - context.pop( - FastRateTemplateModel( - name: editingControllerName.text, - ww: int.parse(editingControllerWw.text), - tsb: int.parse(editingControllerTsb.text), - xc: int.parse(editingControllerXc.text), - tr: int.parse(editingControllerTr.text), - fh: int.parse(editingControllerFh.text), - jl: int.parse(editingControllerJl.text), - special: int.parse(editingControllerSpecial.text), - special2: int.parse(editingControllerSpecial2.text), - ), - ); - }, ), ], ), diff --git a/lib/features/rate/view/fast_rate_template_page.dart b/lib/features/rate/view/fast_rate_template_page.dart index 7dd8e973f..16e668874 100644 --- a/lib/features/rate/view/fast_rate_template_page.dart +++ b/lib/features/rate/view/fast_rate_template_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/rate/view/fast_rate_edit_template_page.dart'; import 'package:tsdm_client/features/rate/widgets/fast_rate_template_card.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; @@ -10,6 +11,7 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page to view and edit fast rate templates. @@ -38,7 +40,7 @@ class _FastRateTemplatePageState extends State with Logger builder: (context, snapshot) { if (snapshot.hasError) { error('failed to load fast rate templates: ${snapshot.error!}'); - return Center(child: Text(context.t.general.failedToLoad)); + return AppStateView(error: true, icon: Icons.error_outline, message: context.t.general.failedToLoad); } if (!snapshot.hasData) { @@ -48,31 +50,28 @@ class _FastRateTemplatePageState extends State with Logger final allTemplates = snapshot.data!; if (allTemplates.isEmpty) { - return Center( - child: Text( - tr.empty, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ); + return AppStateView(icon: Icons.star_rate_outlined, message: tr.empty); } - return SingleChildScrollView( - child: Padding( - padding: edgeInsetsL12T8R12, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: allTemplates - .map( - (e) => FastRateTemplateCard( - key: ValueKey('FastRateTemplateCard_${e.hashCode}'), - rateTemplate: e, - allowEdit: !widget.pick, - ), - ) - .toList(), - ), - ), + return AppCenteredList( + builder: (context, horizontal, width) { + final columns = appColumnsFor(width); + return ListView.separated( + padding: horizontal.add(edgeInsetsT12).add(context.safePadding()), + itemCount: appRowCount(allTemplates.length, columns), + separatorBuilder: (_, _) => appListSeparator, + itemBuilder: (context, row) => AppColumnsRow( + row: row, + columns: columns, + count: allTemplates.length, + itemBuilder: (context, index) => FastRateTemplateCard( + key: ValueKey('FastRateTemplateCard_${allTemplates[index].hashCode}'), + rateTemplate: allTemplates[index], + allowEdit: !widget.pick, + ), + ), + ); + }, ); }, ); diff --git a/lib/features/rate/view/rate_log_page.dart b/lib/features/rate/view/rate_log_page.dart index 39f12b8e8..695e50238 100644 --- a/lib/features/rate/view/rate_log_page.dart +++ b/lib/features/rate/view/rate_log_page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:fpdart/fpdart.dart' show FpdartOnIterableOfIterable; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; @@ -10,10 +9,12 @@ import 'package:tsdm_client/extensions/date_time.dart'; import 'package:tsdm_client/features/rate/bloc/rate_log_cubit.dart'; import 'package:tsdm_client/features/rate/models/models.dart'; import 'package:tsdm_client/features/rate/repository/rate_repository.dart'; +import 'package:tsdm_client/features/rate/widgets/fast_rate_template_card.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/quoted_text.dart'; @@ -42,130 +43,82 @@ class RateLogPage extends StatefulWidget { class _RateLogPageState extends State with SingleTickerProviderStateMixin { late final TabController tabController; - Widget _buildAccumulatedCard(RateLogAccumulatedItem logItem) => Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': logItem.uid}), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - minTileHeight: 0, - minVerticalPadding: 0, - leading: HeroUserAvatar(username: logItem.username, avatarUrl: null, disableHero: true), - title: Text(logItem.username), - subtitle: DefaultTextStyle( - style: Theme.of(context).textTheme.labelSmall!.copyWith(color: Theme.of(context).colorScheme.outline), - child: Wrap( - children: logItem.firstRateTime != logItem.lastRateTime - ? [ - Text(logItem.firstRateTime.yyyyMMDDHHMMSS()), - const Text(' ~ '), - Text(logItem.lastRateTime.yyyyMMDDHHMMSS()), - ] - : [Text(logItem.firstRateTime.yyyyMMDDHHMMSS())], - ), - ), - ), - sizedBoxW8H8, - if (logItem.reason.isNotEmpty) ...[ - Flexible( - child: QuotedText( - logItem.reason, - //style: TextStyle(color: Theme.of(context).colorScheme.secondary), + /// One rate log surface: rater (avatar, name, time) on top, scores as chips, the reason quoted under them. + Widget _buildLogCard({ + required String uid, + required String username, + required String time, + required String reason, + required List scores, + }) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return AppSurface( + onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': uid}), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + HeroUserAvatar(username: username, avatarUrl: null, disableHero: true), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + Text(time, style: textTheme.labelSmall?.copyWith(color: colorScheme.outline)), + ], ), ), - sizedBoxW12H12, ], - DefaultTextStyle( - style: Theme.of(context).textTheme.bodyMedium!, - child: Wrap( - children: logItem.attrMap.entries - .map( - (attr) => [ - Text(attr.key), - sizedBoxW8H8, - Text( - '${attr.value > 0 ? "+" : ""}${attr.value}', - style: TextStyle( - color: attr.value > 0 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - ), - ), - sizedBoxW12H12, - ], - ) - .flatten - .toList(), - ), - ), - ], - ), + ), + sizedBoxW8H8, + Wrap(spacing: 6, runSpacing: 6, children: scores), + if (reason.isNotEmpty) ...[sizedBoxW8H8, QuotedText(reason)], + ], ), - ), + ); + } + + Widget _buildAccumulatedCard(RateLogAccumulatedItem logItem) => _buildLogCard( + uid: logItem.uid, + username: logItem.username, + time: logItem.firstRateTime != logItem.lastRateTime + ? '${logItem.firstRateTime.yyyyMMDDHHMMSS()} ~ ${logItem.lastRateTime.yyyyMMDDHHMMSS()}' + : logItem.firstRateTime.yyyyMMDDHHMMSS(), + reason: logItem.reason, + scores: [for (final attr in logItem.attrMap.entries) RateScoreChip(name: attr.key, value: attr.value)], ); - Widget _buildSimpleCard(RateLogItem logItem) => Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async => context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': logItem.uid}), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - contentPadding: EdgeInsets.zero, - minTileHeight: 0, - minVerticalPadding: 0, - leading: HeroUserAvatar(username: logItem.username, avatarUrl: null, disableHero: true), - title: Text(logItem.username), - subtitle: Text( - logItem.time.yyyyMMDDHHMMSS(), - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ), - sizedBoxW8H8, - if (logItem.reason.isNotEmpty) ...[ - Flexible( - child: QuotedText( - logItem.reason, - //style: TextStyle(color: Theme.of(context).colorScheme.secondary), - ), - ), - sizedBoxW12H12, - ], - DefaultTextStyle( - style: Theme.of(context).textTheme.bodyMedium!, - child: Wrap( - children: [ - Text(logItem.attrName), - sizedBoxW8H8, - Text( - '${logItem.attrValue > 0 ? "+" : ""}${logItem.attrValue}', - style: TextStyle( - color: logItem.attrValue > 0 - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline, - ), - ), - ], - ), - ), - ], - ), - ), - ), + Widget _buildSimpleCard(RateLogItem logItem) => _buildLogCard( + uid: logItem.uid, + username: logItem.username, + time: logItem.time.yyyyMMDDHHMMSS(), + reason: logItem.reason, + scores: [RateScoreChip(name: logItem.attrName, value: logItem.attrValue)], ); + Widget _buildList(int count, IndexedWidgetBuilder builder) { + if (count == 0) { + return AppStateView(message: context.t.general.noData); + } + return AppCenteredList( + builder: (context, horizontal, width) => ListView.separated( + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)).add(context.safePadding()), + separatorBuilder: (_, _) => appListSeparator, + itemCount: count, + itemBuilder: builder, + ), + ); + } + @override void initState() { super.initState(); @@ -204,18 +157,11 @@ class _RateLogPageState extends State with SingleTickerProviderStat RateLogStatus.success => TabBarView( controller: tabController, children: [ - ListView.separated( - padding: edgeInsetsL12T4R12B4, - separatorBuilder: (_, _) => sizedBoxW4H4, - itemCount: state.accumulatedLogItems.length, - itemBuilder: (_, idx) => _buildAccumulatedCard(state.accumulatedLogItems[idx]), - ), - ListView.separated( - padding: edgeInsetsL12T4R12B4, - separatorBuilder: (_, _) => sizedBoxW4H4, - itemCount: state.logItems.length, - itemBuilder: (_, idx) => _buildSimpleCard(state.logItems[idx]), + _buildList( + state.accumulatedLogItems.length, + (_, idx) => _buildAccumulatedCard(state.accumulatedLogItems[idx]), ), + _buildList(state.logItems.length, (_, idx) => _buildSimpleCard(state.logItems[idx])), ], ), }; diff --git a/lib/features/rate/view/rate_post_page.dart b/lib/features/rate/view/rate_post_page.dart index e2aa5057a..d3c5b51f5 100644 --- a/lib/features/rate/view/rate_post_page.dart +++ b/lib/features/rate/view/rate_post_page.dart @@ -17,6 +17,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/debounce_buttons.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -203,9 +204,14 @@ class _RatePostPageState extends State with LoggerMixin { CustomAlertDialog.sync( title: Text(tr.reason), content: Column( + mainAxisSize: MainAxisSize.min, children: state.info!.defaultReasonList .map( (e) => ListTile( + leading: Icon( + e == reasonController.text ? Icons.radio_button_checked : Icons.radio_button_unchecked, + color: e == reasonController.text ? Theme.of(context).colorScheme.primary : null, + ), title: Text(e), onTap: () { context.pop(); @@ -224,88 +230,105 @@ class _RatePostPageState extends State with LoggerMixin { ); } + final colorScheme = Theme.of(context).colorScheme; return Form( key: formKey, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - sizedBoxW12H12, - // Body title. - Row( - children: [ - sizedBoxW12H12, - Expanded( - child: Text( - tr.description(username: widget.username, floor: widget.floor), - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), - ), - ), - sizedBoxW12H12, - ], - ), - sizedBoxW12H12, Expanded( - child: GridView( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 240, - mainAxisExtent: 80, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - padding: edgeInsetsL12T8R12, - children: scoreWidgetList, - ), - ), - sizedBoxW8H8, - Row( - children: [ - sizedBoxW12H12, - Expanded( - child: TextFormField( - controller: reasonController, - decoration: InputDecoration(labelText: tr.reason, suffixIcon: defaultReasonButton), + child: AppCenteredList( + maxWidth: appFormMaxWidth, + // Not a lazy list: every score field stays built, so the form validates all of them. + builder: (context, horizontal, width) => SingleChildScrollView( + padding: horizontal.add(const EdgeInsets.symmetric(vertical: 12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Body title: whom and which floor is rated. + AppSurface( + child: Row( + children: [ + const AppIconTile(Icons.star_rate_outlined), + sizedBoxW12H12, + Expanded( + child: Text( + tr.description(username: widget.username, floor: widget.floor), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: colorScheme.secondary), + ), + ), + ], + ), + ), + appListSeparator, + AppFormSection( + children: [ + LayoutBuilder( + builder: (context, constraints) { + // Score fields carry a helper and a range: one per row on phones, more on wide windows + // and fewer again with large text. Heights follow the content, errors are never clipped. + final fit = (constraints.maxWidth / MediaQuery.textScalerOf(context).scale(220)).floor(); + final perRow = fit < 1 ? 1 : (fit > 3 ? 3 : fit); + final fieldWidth = (constraints.maxWidth - 12 * (perRow - 1)) / perRow; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final field in scoreWidgetList) SizedBox(width: fieldWidth, child: field), + ], + ); + }, + ), + ], + ), + appListSeparator, + AppFormSection( + gap: 4, + children: [ + TextFormField( + controller: reasonController, + decoration: InputDecoration(labelText: tr.reason, suffixIcon: defaultReasonButton), + ), + SectionSwitchListTile( + contentPadding: EdgeInsets.zero, + value: noticeAuthor || state.info!.noticeAuthorForced, + title: Text(tr.noticeAuthor), + subtitle: state.info!.noticeAuthorForced ? Text(tr.noticeAuthorForced) : null, + onChanged: state.info!.noticeAuthorForced + ? null + : (value) { + setState(() { + noticeAuthor = value; + }); + }, + ), + ], + ), + ], ), ), - sizedBoxW12H12, - ], - ), - SectionSwitchListTile( - value: noticeAuthor || state.info!.noticeAuthorForced, - title: Text(tr.noticeAuthor), - subtitle: state.info!.noticeAuthorForced ? Text(tr.noticeAuthorForced) : null, - onChanged: state.info!.noticeAuthorForced - ? null - : (value) { - setState(() { - noticeAuthor = value; - }); - }, - ), - if (state.status == RateStatus.rateFailed) - Padding( - padding: edgeInsetsL12T4R12B4, - child: Text( - state.failedReason ?? tr.failedToRate, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.error), - ), ), - sizedBoxW12H12, - Row( - children: [ - sizedBoxW12H12, - Expanded( - child: DebounceFilledButton( + ), + // Submit stays reachable above the keyboard; a refused rate shows the forum's reason right above it. + AppBottomActionBar( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.status == RateStatus.rateFailed) ...[ + AppNoticeBanner(tone: AppNoticeTone.error, message: state.failedReason ?? tr.failedToRate), + sizedBoxW8H8, + ], + DebounceFilledButton( shouldDebounce: state.status.isLoading(), onPressed: () async => _rate(context, state.info!), child: Text(tr.title), ), - ), - sizedBoxW12H12, - ], + ], + ), ), - Padding(padding: context.safePadding()), ], ), ); diff --git a/lib/features/rate/widgets/fast_rate_template_card.dart b/lib/features/rate/widgets/fast_rate_template_card.dart index 2b128eef6..55f4417d2 100644 --- a/lib/features/rate/widgets/fast_rate_template_card.dart +++ b/lib/features/rate/widgets/fast_rate_template_card.dart @@ -9,7 +9,46 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/widgets/adaptive_ink_response.dart'; -import 'package:tsdm_client/widgets/attr_block.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// A score of a rate: attribute name and signed value; positive in the primary color, negative in the error color, +/// zero dimmed. +class RateScoreChip extends StatelessWidget { + /// Constructor. + const RateScoreChip({required this.name, required this.value, super.key}); + + /// Attribute name. + final String name; + + /// Score value. + final int value; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final valueColor = value > 0 + ? colorScheme.primary + : value < 0 + ? colorScheme.error + : colorScheme.outline; + return AppInsetBlock( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: '$name '), + TextSpan( + text: '${value > 0 ? "+" : ""}$value', + style: TextStyle(color: valueColor, fontWeight: FontWeight.bold), + ), + ], + ), + style: textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ); + } +} /// Actions in popup menu. enum _MenuAction { @@ -118,46 +157,48 @@ class _FastRateTemplateCardState extends State { @override Widget build(BuildContext context) { final tr = context.t.fastRateTemplate; - final nameStyle = Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.outline); - final valueStyle = Theme.of(context).textTheme.labelMedium; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; return Card( - clipBehavior: Clip.hardEdge, + clipBehavior: Clip.antiAlias, margin: EdgeInsets.zero, child: AdaptiveInkResponse( onTapUp: widget.allowEdit ? (pos) async => openMenu(pos.globalPosition) : (_) async => popBack(), onAdaptiveContextTap: (pos) => openMenu(pos.globalPosition), child: Padding( - padding: edgeInsetsL12T12R12B12, + padding: edgeInsetsL16T12R16B12, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - rateTemplate.name, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary), + Row( + children: [ + const AppIconTile(Icons.star_rate_outlined, size: 32), + sizedBoxW12H12, + Expanded( + child: Text( + rateTemplate.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + if (widget.allowEdit) Icon(Icons.more_vert, size: 18, color: colorScheme.outline), + ], ), sizedBoxW8H8, Wrap( - spacing: 8, - runSpacing: 8, + spacing: 6, + runSpacing: 6, children: [ - AttrBlock(name: tr.ww, value: '${rateTemplate.ww}', nameStyle: nameStyle, valueStyle: valueStyle), - AttrBlock(name: tr.tsb, value: '${rateTemplate.tsb}', nameStyle: nameStyle, valueStyle: valueStyle), - AttrBlock(name: tr.xc, value: '${rateTemplate.xc}', nameStyle: nameStyle, valueStyle: valueStyle), - AttrBlock(name: tr.tr, value: '${rateTemplate.tr}', nameStyle: nameStyle, valueStyle: valueStyle), - AttrBlock(name: tr.fh, value: '${rateTemplate.fh}', nameStyle: nameStyle, valueStyle: valueStyle), - AttrBlock(name: tr.jl, value: '${rateTemplate.jl}', nameStyle: nameStyle, valueStyle: valueStyle), - AttrBlock( - name: tr.special, - value: '${rateTemplate.special}', - nameStyle: nameStyle, - valueStyle: valueStyle, - ), - AttrBlock( - name: tr.special2, - value: '${rateTemplate.special2}', - nameStyle: nameStyle, - valueStyle: valueStyle, - ), + RateScoreChip(name: tr.ww, value: rateTemplate.ww), + RateScoreChip(name: tr.tsb, value: rateTemplate.tsb), + RateScoreChip(name: tr.xc, value: rateTemplate.xc), + RateScoreChip(name: tr.tr, value: rateTemplate.tr), + RateScoreChip(name: tr.fh, value: rateTemplate.fh), + RateScoreChip(name: tr.jl, value: rateTemplate.jl), + RateScoreChip(name: tr.special, value: rateTemplate.special), + RateScoreChip(name: tr.special2, value: rateTemplate.special2), ], ), ], diff --git a/lib/features/red_packet/utils/daily_red_packet_record.dart b/lib/features/red_packet/utils/daily_red_packet_record.dart new file mode 100644 index 000000000..71dc04dde --- /dev/null +++ b/lib/features/red_packet/utils/daily_red_packet_record.dart @@ -0,0 +1,21 @@ +/// The local record of a claimed daily red packet. +/// +/// The forum homepage carries the packet only while it can be claimed; once claimed the page looks the same as a day +/// without a packet, so the app remembers the claims it made (or that the server answered "already claimed") per +/// account and shows "claimed" instead of "none" for the rest of that day. Only this app's own claims are known: a +/// claim made on the website is not. +library; + +/// Storage key of the record of the account [uid]: the value is the `dateflag` (`YYYYMMDD`) of the claimed packet. +String dailyRedPacketClaimKey(int uid) => 'dailyRedPacketClaimed.$uid'; + +/// The `dateflag` form of [day], `YYYYMMDD` like the forum's. +String dailyRedPacketDateFlag(DateTime day) => + '${day.year.toString().padLeft(4, '0')}${day.month.toString().padLeft(2, '0')}${day.day.toString().padLeft(2, '0')}'; + +/// Whether a record of [claimedDateFlag] means the packet of today ([now], local time) was claimed. +/// +/// The site's day is not known here; the local calendar day stands in for it, so around midnight the record may be +/// a few hours off. The record is only used when the page offers no packet, a packet on the page always wins. +bool dailyRedPacketClaimedToday(String? claimedDateFlag, {DateTime? now}) => + claimedDateFlag != null && claimedDateFlag == dailyRedPacketDateFlag(now ?? DateTime.now()); diff --git a/lib/features/red_packet/widgets/daily_red_packet_button.dart b/lib/features/red_packet/widgets/daily_red_packet_button.dart index 5a2394804..42e89d391 100644 --- a/lib/features/red_packet/widgets/daily_red_packet_button.dart +++ b/lib/features/red_packet/widgets/daily_red_packet_button.dart @@ -1,25 +1,45 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:fpdart/fpdart.dart' hide State; +import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/features/red_packet/models/models.dart'; import 'package:tsdm_client/features/red_packet/repository/red_packet_repository.dart'; +import 'package:tsdm_client/features/red_packet/utils/daily_red_packet_record.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +/// Claims today's daily red packet with [formHash], e.g. [RedPacketRepository.claimDaily]. +typedef DailyRedPacketClaim = AsyncEither Function(String formHash); + /// App bar button claiming today's daily red packet. /// -/// Shown while the homepage carries the daily red packet config; hides itself once claimed (or when the server says it -/// was already claimed today). +/// Shown while the homepage carries the daily red packet config. Once claimed (or when the server says it was already +/// claimed today) the icon button hides itself and the [labeled] one shows the claimed state; neither submits again. class DailyRedPacketButton extends StatefulWidget { /// Constructor. const DailyRedPacketButton({ required this.config, required this.formHash, this.repository = const RedPacketRepository(), + this.claim, + this.labeled = false, + this.onClaimed, super.key, }); + /// Show a tonal button with a label instead of an icon button, e.g. on the homepage greeting card. + final bool labeled; + + /// Called with the `dateflag` of the packet once the server answered "claimed" or "already claimed". + final void Function(String dateFlag)? onClaimed; + /// Today's packet. final DailyRedPacketConfig config; @@ -29,52 +49,230 @@ class DailyRedPacketButton extends StatefulWidget { /// Repository talking to the plugin. final RedPacketRepository repository; + /// Claim request, [RedPacketRepository.claimDaily] of [repository] when null (tests pass a fake). + final DailyRedPacketClaim? claim; + @override State createState() => _DailyRedPacketButtonState(); } class _DailyRedPacketButtonState extends State with LoggerMixin { bool _claiming = false; - bool _gone = false; + bool _claimed = false; + + AsyncEither _claimWithRepository(String formHash) => + widget.repository.claimDaily(formHash: formHash); Future _claim() async { + // One request at a time, none after the server confirmed the claim: a second tap in the same frame (before the + // button is rebuilt disabled) does nothing. + if (_claiming || _claimed) { + return; + } final tr = context.t.redPacket.daily; setState(() => _claiming = true); - final result = await widget.repository.claimDaily(formHash: widget.formHash).run(); + final request = widget.claim ?? _claimWithRepository; + final result = await request(widget.formHash).run(); if (!mounted) { return; } final String message; - var gone = false; + var claimed = false; switch (result) { case Left(:final value): handle(value); message = tr.failed(err: value.message ?? '$value'); case Right(:final value) when value.ok: message = tr.claimed(amount: value.amount ?? '?', unit: value.unit ?? widget.config.unit); - gone = true; + claimed = true; case Right(:final value) when value.already: message = value.error ?? tr.alreadyClaimed; - gone = true; + claimed = true; case Right(:final value): message = tr.failed(err: value.error ?? ''); } setState(() { _claiming = false; - _gone = gone; + _claimed = claimed; }); + if (claimed) { + widget.onClaimed?.call(widget.config.dateFlag); + } showSnackBar(context: context, message: message); } @override Widget build(BuildContext context) { - if (_gone) { - return sizedBoxEmpty; - } final tooltip = context.t.redPacket.daily.tooltip; + if (_claimed) { + return widget.labeled ? const DailyRedPacketClaimedButton() : sizedBoxEmpty; + } + if (widget.labeled) { + return FilledButton.tonalIcon( + style: FilledButton.styleFrom(minimumSize: const Size(0, 44)), + icon: _claiming ? sizedCircularProgressIndicator : const Icon(Icons.redeem_outlined), + label: Text(tooltip), + onPressed: _claiming ? null : _claim, + ); + } if (_claiming) { return IconButton(icon: sizedCircularProgressIndicator, tooltip: tooltip, onPressed: null); } return IconButton(icon: const Icon(Icons.redeem_outlined), tooltip: tooltip, onPressed: _claim); } } + +/// Today's daily red packet is claimed: a disabled button saying so, with [hint] as tooltip when given. +class DailyRedPacketClaimedButton extends StatelessWidget { + /// Constructor. + const DailyRedPacketClaimedButton({this.hint, super.key}); + + /// Where the claimed state comes from, when it is not the answer of this run. + final String? hint; + + @override + Widget build(BuildContext context) { + final button = FilledButton.tonalIcon( + style: FilledButton.styleFrom(minimumSize: const Size(0, 44)), + icon: const Icon(Icons.task_alt_outlined), + label: Text(context.t.redPacket.daily.claimedToday), + onPressed: null, + ); + final hint = this.hint; + return hint == null ? button : Tooltip(message: hint, child: button); + } +} + +/// The daily red packet entry of the homepage greeting, always visible with an honest state. +/// +/// * No account: says a login is needed and opens the login page. +/// * A packet ([config]) and a [formHash] on the homepage: [DailyRedPacketButton], the real claim flow; it shows the +/// claimed state once the server answered "claimed" or "already claimed" for that packet, and that answer is +/// recorded for the account ([dailyRedPacketClaimKey]). +/// * No packet, and this app recorded a claim of this account for today: "claimed" (feedback 111), the tooltip says +/// the state is the app's record. The site's day is approximated by the local one ([dailyRedPacketClaimedToday]). +/// * No packet and no record: "none now" and a tap checks the packet again ([onCheck], only the packet of the greeting +/// card is updated, never a reload of the whole homepage); the tooltip says it may be claimed already (on the +/// website, unknown here) or not open yet. +/// +/// The labels are short enough for one line in the half-width button of phones; the full explanation is the tooltip. +class DailyRedPacketEntry extends StatefulWidget { + /// Constructor. + const DailyRedPacketEntry({ + required this.uid, + required this.config, + required this.formHash, + required this.onCheck, + this.checking = false, + this.claim, + super.key, + }); + + /// Logged in account, null without one. + final int? uid; + + /// Today's packet from the homepage, null when the page had none. + final DailyRedPacketConfig? config; + + /// Form hash of the homepage. + final String? formHash; + + /// Check whether the forum offers a packet now, updating only this entry. + final VoidCallback? onCheck; + + /// A check started by [onCheck] is running: the entry waits for it instead of starting another one. + final bool checking; + + /// Claim request for [DailyRedPacketButton.claim]. + final DailyRedPacketClaim? claim; + + @override + State createState() => _DailyRedPacketEntryState(); +} + +class _DailyRedPacketEntryState extends State { + /// `dateflag` of the packet this app last claimed for [DailyRedPacketEntry.uid], null when none is recorded. + String? _claimedDateFlag; + + /// The account the record was read for. + int? _recordUid; + + StorageProvider? get _storage => getIt.isRegistered() ? getIt.get() : null; + + Future _readRecord() async { + final uid = widget.uid; + final storage = _storage; + _recordUid = uid; + if (uid == null || storage == null) { + setState(() => _claimedDateFlag = null); + return; + } + final flag = await storage.getString(dailyRedPacketClaimKey(uid)); + // The account may have changed while reading. + if (mounted && _recordUid == uid) { + setState(() => _claimedDateFlag = flag); + } + } + + void _onClaimed(String dateFlag) { + final uid = widget.uid; + if (uid == null) { + return; + } + setState(() => _claimedDateFlag = dateFlag); + unawaited(_storage?.saveString(dailyRedPacketClaimKey(uid), dateFlag)); + } + + @override + void initState() { + super.initState(); + unawaited(_readRecord()); + } + + @override + void didUpdateWidget(DailyRedPacketEntry oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.uid != widget.uid) { + unawaited(_readRecord()); + } + } + + @override + Widget build(BuildContext context) { + final tr = context.t.redPacket.daily; + final uid = widget.uid; + final config = widget.config; + final formHash = widget.formHash; + if (uid == null) { + return OutlinedButton.icon( + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), + icon: const Icon(Icons.login_outlined), + label: Text(tr.needLogin), + onPressed: () async => context.pushNamed(ScreenPaths.login), + ); + } + if (config != null && formHash != null && formHash.isNotEmpty) { + return DailyRedPacketButton( + // A new account or a new day starts a new button: nothing of the previous claim state carries over. + key: ValueKey('dailyRedPacket-$uid-${config.dateFlag}'), + config: config, + formHash: formHash, + claim: widget.claim, + labeled: true, + onClaimed: _onClaimed, + ); + } + if (_recordUid == uid && dailyRedPacketClaimedToday(_claimedDateFlag)) { + return DailyRedPacketClaimedButton(key: const ValueKey('dailyRedPacket-recorded'), hint: tr.claimedRecordedHint); + } + return Tooltip( + message: tr.unavailableHint, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom(minimumSize: const Size(0, 44)), + icon: widget.checking ? sizedCircularProgressIndicator : const Icon(Icons.redeem_outlined), + label: Text(tr.unavailable), + onPressed: widget.checking ? null : widget.onCheck, + ), + ); + } +} diff --git a/lib/features/red_packet/widgets/red_packet_card.dart b/lib/features/red_packet/widgets/red_packet_card.dart index 68a095966..654fbab14 100644 --- a/lib/features/red_packet/widgets/red_packet_card.dart +++ b/lib/features/red_packet/widgets/red_packet_card.dart @@ -5,6 +5,7 @@ import 'package:tsdm_client/features/red_packet/utils/parse_red_packet.dart'; import 'package:tsdm_client/features/red_packet/widgets/red_packet_dialog.dart'; import 'package:tsdm_client/features/thread/v1/bloc/thread_bloc.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Red envelope colors used by the forum, kept the same in both themes. const _envelopeTop = Color(0xFFD1302F); @@ -33,7 +34,9 @@ class RedPacketCard extends StatelessWidget { final formHash = context.readOrNull()?.state.replyParameters?.formHash; return Card( elevation: elevation, - margin: EdgeInsets.zero, + // Same inner radius and room as the other blocks embedded in a floor, no hairline on the envelope colors. + margin: const EdgeInsets.symmetric(vertical: 4), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), clipBehavior: Clip.antiAlias, color: _envelopeBottom, child: InkWell( diff --git a/lib/features/red_packet/widgets/red_packet_dialog.dart b/lib/features/red_packet/widgets/red_packet_dialog.dart index da3fcd1f2..f04c6cd64 100644 --- a/lib/features/red_packet/widgets/red_packet_dialog.dart +++ b/lib/features/red_packet/widgets/red_packet_dialog.dart @@ -10,6 +10,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/themes/widget_themes.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Show the red packet of thread [tid]. /// @@ -23,7 +24,12 @@ Future showRedPacketDialog(BuildContext context, {required String tid, req /// Dialog showing a red packet: its info, the claim button and the claimed shares (手氣榜). class RedPacketDialog extends StatefulWidget { /// Constructor. - const RedPacketDialog({required this.tid, required this.formHash, this.repository = const RedPacketRepository(), super.key}); + const RedPacketDialog({ + required this.tid, + required this.formHash, + this.repository = const RedPacketRepository(), + super.key, + }); /// Thread id of the packet. final String tid; @@ -175,37 +181,68 @@ class _RedPacketDialogState extends State with LoggerMixin { // The server reports `claimed` instead of `open` once the user has a share; do the same after a grab in this dialog. final shownState = claimed && info.state == RedPacketState.open ? RedPacketState.claimed : info.state; + final colorScheme = theme.colorScheme; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - if (info.from.isNotEmpty) Text(info.from, style: secondary), - sizedBoxW4H4, - Text(info.bless, style: theme.textTheme.titleLarge), - sizedBoxW4H4, - Text('${info.isRandom ? tr.modeRandom : tr.modeEven} · ${_stateText(context.t, shownState)}', style: secondary), - if (claimed) ...[ - sizedBoxW12H12, - Wrap( - spacing: 8, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisSize: MainAxisSize.min, + if (info.from.isNotEmpty) ...[Text(info.from, style: secondary), sizedBoxW4H4], + Text(info.bless, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + sizedBoxW8H8, + Wrap( + spacing: 6, + runSpacing: 6, children: [ - Icon(Icons.redeem, color: theme.colorScheme.primary), - sizedBoxW8H8, - Text( + AppInfoPill( + icon: info.isRandom ? Icons.shuffle_outlined : Icons.drag_handle_outlined, + label: info.isRandom ? tr.modeRandom : tr.modeEven, + ), + AppInfoPill(icon: Icons.flag_outlined, label: _stateText(context.t, shownState)), + ], + ), + ], + ), + ), + if (claimed) ...[ + sizedBoxW12H12, + AppInsetBlock( + color: colorScheme.primaryContainer, + padding: edgeInsetsL12T12R12B12, + child: Row( + children: [ + Icon(Icons.redeem, color: colorScheme.onPrimaryContainer), + sizedBoxW8H8, + Expanded( + child: Text( grabbed != null && !grabbed.already ? tr.grabbed(amount: claimedAmount ?? '?', unit: grabbed.unit ?? info.unit) : tr.claimed(amount: claimedAmount ?? '?', unit: info.unit), - style: theme.textTheme.titleMedium, + style: theme.textTheme.titleMedium?.copyWith(color: colorScheme.onPrimaryContainer), + ), + ), + if (claimedBest) ...[ + sizedBoxW8H8, + AppInsetBlock( + color: colorScheme.primary, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.emoji_events_outlined, size: 14, color: colorScheme.onPrimary), + sizedBoxW4H4, + Text(tr.best, style: theme.textTheme.labelSmall?.copyWith(color: colorScheme.onPrimary)), + ], + ), ), ], - ), - if (claimedBest) Chip(label: Text(tr.best), visualDensity: VisualDensity.compact), - ], + ], + ), ), ], if (canClaim) ...[ @@ -213,8 +250,16 @@ class _RedPacketDialogState extends State with LoggerMixin { if (info.hasPassword) ...[ TextField( controller: _passwordController, - decoration: InputDecoration(hintText: tr.passwordHint, border: const OutlineInputBorder(), isDense: true), + decoration: InputDecoration( + hintText: tr.passwordHint, + prefixIcon: const Icon(Icons.password_outlined), + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + isDense: true, + ), enabled: !_grabbing, + textInputAction: TextInputAction.done, + onSubmitted: _grabbing ? null : (_) => unawaited(_grab()), ), sizedBoxW8H8, ], @@ -226,7 +271,7 @@ class _RedPacketDialogState extends State with LoggerMixin { ], if (_grabError != null) ...[ sizedBoxW8H8, - Text(_grabError!, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error)), + AppNoticeBanner(message: _grabError!, tone: AppNoticeTone.error, selectable: true), ], if (canSeeRecords) ...[sizedBoxW12H12, _buildRecords(context, info)], ], @@ -251,47 +296,65 @@ class _RedPacketDialogState extends State with LoggerMixin { ); } if (records.error != null) { - return Text(records.error!, style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.outline)); + return AppNoticeBanner(message: records.error!, tone: AppNoticeTone.error, selectable: true); } final unit = records.unit ?? info.unit; + final colorScheme = theme.colorScheme; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Text( + AppSectionHeader( records.claimed != null && records.shares != null ? tr.recordsTitle(claimed: records.claimed!, shares: records.shares!) : tr.records, - style: theme.textTheme.titleSmall, + icon: Icons.leaderboard_outlined, + padding: const EdgeInsets.only(bottom: 8), ), - sizedBoxW4H4, if (records.records.isEmpty) - Text(tr.noRecords, style: theme.textTheme.bodySmall) + Text(tr.noRecords, style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)) else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240), - child: ListView.builder( - shrinkWrap: true, - itemCount: records.records.length, - itemBuilder: (context, index) { - final record = records.records[index]; - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Text(record.username, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(record.time), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (record.isBest) ...[ - Icon(Icons.emoji_events_outlined, size: smallIconSize, color: theme.colorScheme.primary), - sizedBoxW4H4, + // The records scroll with the dialog; a nested list would fight the dialog's own scrolling. + AppInsetBlock( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Column( + children: [ + for (final (index, record) in records.records.indexed) ...[ + if (index > 0) Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.6)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.username, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + Text( + record.time, + style: theme.textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + if (record.isBest) ...[ + Icon(Icons.emoji_events_outlined, size: smallIconSize, color: colorScheme.primary), + sizedBoxW4H4, + ], + Text( + '${record.amount} $unit', + style: theme.textTheme.labelLarge?.copyWith(color: colorScheme.primary), + ), ], - Text('${record.amount} $unit'), - ], + ), ), - ); - }, + ], + ], ), ), ], @@ -300,17 +363,21 @@ class _RedPacketDialogState extends State with LoggerMixin { Widget _buildBody(BuildContext context) { final tr = context.t.redPacket; - final theme = Theme.of(context); if (_loading) { - return const Padding(padding: edgeInsetsL24T24R24B24, child: Center(child: sizedCircularProgressIndicator)); + return const Padding( + padding: edgeInsetsL24T24R24B24, + child: Center(child: sizedCircularProgressIndicator), + ); } final info = _info; if (info != null) { return _buildInfo(context, info); } - return Text( - _needLogin ? tr.needLogin : (_error ?? tr.noPacket), - style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.outline), + return AppNoticeBanner( + message: _needLogin ? tr.needLogin : (_error ?? tr.noPacket), + tone: _needLogin || _error == null ? AppNoticeTone.info : AppNoticeTone.error, + icon: _needLogin ? Icons.login : null, + selectable: !_needLogin, ); } @@ -318,7 +385,18 @@ class _RedPacketDialogState extends State with LoggerMixin { Widget build(BuildContext context) { final tr = context.t.redPacket; return AlertDialog( - title: Row(children: [const Icon(Icons.redeem_outlined), sizedBoxW8H8, Text(tr.title)]), + title: Row( + children: [ + AppIconTile( + Icons.redeem_outlined, + size: 36, + color: Theme.of(context).colorScheme.errorContainer, + foregroundColor: Theme.of(context).colorScheme.onErrorContainer, + ), + sizedBoxW12H12, + Expanded(child: Text(tr.title)), + ], + ), content: SizedBox(width: 360, child: SingleChildScrollView(child: _buildBody(context))), actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.t.general.close))], ); diff --git a/lib/features/root/view/singleton.dart b/lib/features/root/view/singleton.dart index 7cd7ebe8b..7d8714bf6 100644 --- a/lib/features/root/view/singleton.dart +++ b/lib/features/root/view/singleton.dart @@ -18,6 +18,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/git_info.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// The app wide singleton stands on top of all other pages to act on different events in app. @@ -51,27 +52,57 @@ class _RootSingletonState extends State with LoggerMixin { return RootPage( DialogPaths.updateNotice, CustomAlertDialog.sync( - title: Text(tr.updatePage.availableDialog.title), + title: AppDialogTitle(icon: Icons.system_update_outlined, title: tr.updatePage.availableDialog.title), content: SizedBox( width: math.min(size.width * 0.8, 800), height: math.min(size.height * 0.6, 600), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - tr.updatePage.availableDialog.version(version: info.version), - style: Theme.of( - context, - ).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.primary), + // The new version as a pill, then the changelog in its own scrolling block. + Align( + alignment: AlignmentDirectional.centerStart, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Text( + tr.updatePage.availableDialog.version(version: info.version), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + sizedBoxW12H12, + AppSectionHeader( + tr.updatePage.availableDialog.changelog, + icon: Icons.history_outlined, + padding: const EdgeInsets.only(bottom: 8), + ), + Expanded( + child: AppInsetBlock( + outlined: true, + padding: EdgeInsets.zero, + child: Markdown(data: info.changelog), + ), ), - sizedBoxW8H8, - Expanded(child: Markdown(data: info.changelog)), ], ), ), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(tr.general.cancel)), + // Stays a text button (tests find it by type); the tonal colors mark it as the main action. TextButton( + style: TextButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, + ), onPressed: () => Navigator.of(context).pop(true), child: Text(tr.settingsPage.othersSection.update), ), diff --git a/lib/features/search/view/search_page.dart b/lib/features/search/view/search_page.dart index bda4ef113..86582b2aa 100644 --- a/lib/features/search/view/search_page.dart +++ b/lib/features/search/view/search_page.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; -import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/features/jump_page/widgets/jump_page_dialog.dart'; import 'package:tsdm_client/features/open_in_app/view/open_in_app_page.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; @@ -12,6 +11,7 @@ import 'package:tsdm_client/features/search/repository/search_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/thread_card/thread_card.dart'; import 'package:tsdm_client/widgets/debounce_buttons.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -224,19 +224,20 @@ class _SearchPageState extends State with LoggerMixin { } Widget _buildSearchButton(BuildContext context, SearchState state) { - return Row( - children: [ - Expanded( - child: SizedBox( - height: 50, - child: DebounceFilledButton( - shouldDebounce: state.status.isSearching(), - onPressed: () async => _search(context), - child: Text(context.t.searchPage.form.search), - ), - ), + return SizedBox( + height: 48, + child: DebounceFilledButton( + shouldDebounce: state.status.isSearching(), + onPressed: () async => _search(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.search_outlined), + sizedBoxW8H8, + Flexible(child: Text(context.t.searchPage.form.search, maxLines: 1, overflow: TextOverflow.ellipsis)), + ], ), - ], + ), ); } @@ -286,142 +287,207 @@ class _SearchPageState extends State with LoggerMixin { } Widget _buildSearchForm(BuildContext context, SearchState state) { - return Form( - key: formKey, - child: Column( - children: [ - TextFormField( - autofocus: true, - controller: keywordController, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.abc_outlined), - labelText: context.t.searchPage.form.keyword, - ), - validator: (v) { - // FIXME: Extra validation not graceful at all. - // Purpose is to skip keyword validation when both author and forum id are valid and not `any`. - // The server allows searching without keyword when author or forum id is set. - // If author or forum id is not valid, it's unnecessary to validate keyword. - if (_validateAuthor(context, authorController.text) != null || - _validateFid(context, fidController.text) != null) { - return null; - } - // Validation only fails when running with keyword field, in other words author and forum id are `any`. - // It's fine to have an empty keyword when author or forum id is not `any`. - if (v == null || v.isEmpty && authorController.text.trim().isEmpty && fidController.text == '0') { - return context.t.searchPage.form.keywordEmpty; - } - if (v.contains('%')) { - return context.t.searchPage.form.keywordInvalid; - } - return null; - }, - ), - TextFormField( - controller: authorController, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.person_outline), - labelText: context.t.searchPage.form.author, - suffixText: unlimitedAuthor ? context.t.searchPage.form.any : null, - ), - onChanged: (v) { - setState(() { - unlimitedAuthor = v.trim().isEmpty; - }); - }, - validator: (v) => _validateAuthor(context, v), - ), - TextFormField( - controller: fidController, - keyboardType: TextInputType.number, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.forum_outlined), - labelText: context.t.searchPage.form.fid, - suffixText: unlimitedFid ? context.t.searchPage.form.any : null, - ), - onChanged: (v) { - setState(() { - unlimitedFid = fidController.text == '0'; - }); - }, - validator: (v) => _validateFid(context, v), - ), - _buildSearchButton(context, state), - ].insertBetween(sizedBoxW12H12), + final keywordField = TextFormField( + autofocus: true, + controller: keywordController, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.abc_outlined), + labelText: context.t.searchPage.form.keyword, + ), + validator: (v) { + // FIXME: Extra validation not graceful at all. + // Purpose is to skip keyword validation when both author and forum id are valid and not `any`. + // The server allows searching without keyword when author or forum id is set. + // If author or forum id is not valid, it's unnecessary to validate keyword. + if (_validateAuthor(context, authorController.text) != null || + _validateFid(context, fidController.text) != null) { + return null; + } + // Validation only fails when running with keyword field, in other words author and forum id are `any`. + // It's fine to have an empty keyword when author or forum id is not `any`. + if (v == null || v.isEmpty && authorController.text.trim().isEmpty && fidController.text == '0') { + return context.t.searchPage.form.keywordEmpty; + } + if (v.contains('%')) { + return context.t.searchPage.form.keywordInvalid; + } + return null; + }, + ); + final authorField = TextFormField( + controller: authorController, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.person_outline), + labelText: context.t.searchPage.form.author, + suffixText: unlimitedAuthor ? context.t.searchPage.form.any : null, + ), + onChanged: (v) { + setState(() { + unlimitedAuthor = v.trim().isEmpty; + }); + }, + validator: (v) => _validateAuthor(context, v), + ); + final fidField = TextFormField( + controller: fidController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.forum_outlined), + labelText: context.t.searchPage.form.fid, + suffixText: unlimitedFid ? context.t.searchPage.form.any : null, + ), + onChanged: (v) { + setState(() { + unlimitedFid = fidController.text == '0'; + }); + }, + validator: (v) => _validateFid(context, v), + ); + + // One surface: keyword on its own line, the two filters side by side when there is room, the button last. + return AppSurface( + padding: edgeInsetsL16T16R16B16, + child: Form( + key: formKey, + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= 560; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppSectionHeader( + context.t.searchPage.title, + icon: Icons.manage_search_outlined, + padding: const EdgeInsets.only(bottom: 12), + ), + keywordField, + sizedBoxW12H12, + if (wide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: authorField), + sizedBoxW12H12, + Expanded(child: fidField), + ], + ) + else ...[ + authorField, + sizedBoxW12H12, + fidField, + ], + sizedBoxW16H16, + _buildSearchButton(context, state), + ], + ); + }, + ), ), ); } + /// Title and counts of the result with the page switcher; the switcher goes below the counts on narrow windows. Widget _buildResultInfoRow(BuildContext context, SearchState state) { final searching = state.status.isSearching(); final r = context.t.searchPage.result; - final searchResultCount = - '${r.totalThreadCount(count: '${state.searchResult?.count ?? "-"}')} ' - '${r.pageInfo(total: state.searchResult?.totalPages ?? "-")}'; - return Row( + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final result = state.searchResult; + final info = Row( children: [ + const AppIconTile(Icons.list_alt_outlined, size: 36), + sizedBoxW12H12, Expanded( - child: ListTile( - title: Text(context.t.searchPage.result.title, style: Theme.of(context).textTheme.titleMedium), - subtitle: Text(searchResultCount), - visualDensity: VisualDensity.compact, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(r.title, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text( + '${r.totalThreadCount(count: '${result?.count ?? "-"}')} · ' + '${r.pageInfo(total: result?.totalPages ?? "-")}', + style: textTheme.bodySmall?.copyWith(color: colorScheme.outline), + ), + ], ), ), - // Text(), - IconButton( - icon: const Icon(Icons.arrow_left_outlined), - onPressed: !searching && _hasPreviousPage(state) ? () async => _searchPreviousPage(context, state) : null, - ), - TextButton( - onPressed: !searching && (_hasPreviousPage(state) || _hasNextPage(state)) && state.searchResult != null - ? () async { - await _gotoSpecifiedPage(context, state); - } - : null, - child: Text('${state.searchResult?.currentPage ?? "-"}'), - ), - IconButton( - icon: const Icon(Icons.arrow_right_outlined), - onPressed: !searching && _hasNextPage(state) ? () async => _searchNextPage(context, state) : null, - ), ], ); - } - - Widget _buildSearchResult(BuildContext context, SearchState state) { - if (state.status.isSearching()) { - return const Expanded(child: CenteredCircularIndicator()); - } else if (state.searchResult?.data?.isEmpty ?? true) { - return Expanded(child: Center(child: Text(context.t.searchPage.result.noData))); - } - - return Expanded( - child: ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: ListView.separated( - controller: scrollController, - shrinkWrap: true, - itemCount: state.searchResult!.data!.length, - itemBuilder: (context, index) { - final d = state.searchResult!.data![index]; - return SearchedThreadCard(d); - }, - separatorBuilder: (context, index) => sizedBoxW4H4, - ), + final pager = AppPager( + current: result?.currentPage, + total: result?.totalPages, + onPrevious: !searching && _hasPreviousPage(state) ? () async => _searchPreviousPage(context, state) : null, + onNext: !searching && _hasNextPage(state) ? () async => _searchNextPage(context, state) : null, + onJump: !searching && (_hasPreviousPage(state) || _hasNextPage(state)) && result != null + ? () async => _gotoSpecifiedPage(context, state) + : null, + ); + return AppSurface( + padding: edgeInsetsL12T8R12B8, + child: LayoutBuilder( + builder: (context, constraints) => constraints.maxWidth < 420 + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + info, + sizedBoxW8H8, + Align(alignment: Alignment.centerRight, child: pager), + ], + ) + : Row( + children: [ + Expanded(child: info), + sizedBoxW8H8, + pager, + ], + ), ), ); } Widget _buildBody(BuildContext context, SearchState state) { - return Padding( - padding: edgeInsetsL12T4R12, - child: Column( - children: [ - if (expandForm) _buildSearchForm(context, state), - _buildResultInfoRow(context, state), - _buildSearchResult(context, state), - ].insertBetween(sizedBoxW12H12), - ), + // Form, result info and results share one centered column, at most [appListMaxWidth] wide, in one scroll view: + // with the keyboard up or a large font the form scrolls instead of overflowing. + return AppCenteredList( + builder: (context, side, _) { + final results = state.searchResult?.data; + final Widget resultSliver; + if (state.status.isSearching()) { + resultSliver = const SliverFillRemaining(hasScrollBody: false, child: CenteredCircularIndicator()); + } else if (results == null || results.isEmpty) { + resultSliver = SliverFillRemaining( + hasScrollBody: false, + child: AppStateView( + icon: Icons.search_off_outlined, + message: context.t.searchPage.result.noData, + scrollable: false, + ), + ); + } else { + resultSliver = SliverPadding( + padding: side.copyWith(bottom: 12).add(context.safePadding()), + sliver: SliverList.separated( + itemCount: results.length, + itemBuilder: (context, index) => SearchedThreadCard(results[index]), + separatorBuilder: (context, index) => appListSeparator, + ), + ); + } + return CustomScrollView( + controller: scrollController, + slivers: [ + if (expandForm) + SliverPadding( + padding: side.copyWith(top: 8), + sliver: SliverToBoxAdapter(child: _buildSearchForm(context, state)), + ), + SliverPadding( + padding: side.copyWith(top: 12, bottom: 8), + sliver: SliverToBoxAdapter(child: _buildResultInfoRow(context, state)), + ), + resultSliver, + ], + ); + }, ); } @@ -450,6 +516,9 @@ class _SearchPageState extends State with LoggerMixin { const OpenInAppPageButton(), IconButton( icon: Icon(expandForm ? Icons.expand_less : Icons.expand_more), + tooltip: expandForm + ? MaterialLocalizations.of(context).expandedIconTapHint + : MaterialLocalizations.of(context).collapsedIconTapHint, onPressed: () { setState(() { expandForm = !expandForm; @@ -458,8 +527,7 @@ class _SearchPageState extends State with LoggerMixin { ), ], ), - // FIXME: Support android landscape orientation. - body: _buildBody(context, state), + body: SafeArea(bottom: false, child: _buildBody(context, state)), ); }, ), diff --git a/lib/features/settings/repositories/settings_repository.dart b/lib/features/settings/repositories/settings_repository.dart index c54d6e2a6..02da8722c 100644 --- a/lib/features/settings/repositories/settings_repository.dart +++ b/lib/features/settings/repositories/settings_repository.dart @@ -144,6 +144,7 @@ final class SettingsRepository with LoggerMixin { collapseAppBarWhenScroll: s.extract(_SK.collapseAppBarWhenScroll), threadFloorInteractionMode: s.extract(_SK.threadFloorInteractionMode), textScaleFactor: s.extract(_SK.textScaleFactor), + threadContentScale: s.extract(_SK.threadContentScale), ); } diff --git a/lib/features/settings/view/about_page.dart b/lib/features/settings/view/about_page.dart index be686d7bf..dd083cdaa 100644 --- a/lib/features/settings/view/about_page.dart +++ b/lib/features/settings/view/about_page.dart @@ -5,12 +5,12 @@ import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; -import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/settings/widgets/support_development_dialog.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/clipboard.dart'; import 'package:tsdm_client/utils/git_info.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/section_list_tile.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -19,6 +19,43 @@ class AboutPage extends StatelessWidget { /// Constructor. const AboutPage({super.key}); + /// Logo, name, what the app is and the version, the head of the page. + Widget _buildHead(BuildContext context) { + final tr = context.t.aboutPage; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return AppSurface( + padding: edgeInsetsL16T16R16B16, + child: Column( + children: [ + SvgPicture.asset(assetsLogoSvgPath, width: 120, height: 120), + sizedBoxW12H12, + Text( + context.t.appName, + textAlign: TextAlign.center, + style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + sizedBoxW4H4, + Text( + tr.description, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + sizedBoxW12H12, + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + AppInfoPill(icon: Icons.terminal_outlined, label: appFullVersion, tooltip: tr.version), + AppInfoPill(icon: Icons.balance_outlined, label: 'MIT license', tooltip: tr.license), + ], + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { final tr = context.t.aboutPage; @@ -43,77 +80,99 @@ class AboutPage extends StatelessWidget { ), ], ), - body: ListView( - padding: edgeInsetsT8.add(context.safePadding()), - children: [ - SvgPicture.asset(assetsLogoSvgPath, width: 192, height: 192), - sizedBoxW12H12, - SectionListTile( - leading: const Icon(Icons.contact_support_outlined), - title: Text(tr.whatIsThis), - subtitle: Text(tr.description), + body: SafeArea( + top: false, + child: AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 24), + children: [ + _buildHead(context), + const SizedBox(height: appSurfaceGap), + AppTileGroup( + title: tr.linksSection, + icon: Icons.link_outlined, + children: [ + // The voluntary support entry first: it opens the dialog with the donation code and the feature + // request link, nothing is paid from here. + SectionListTile( + leading: Icon(Icons.favorite_border, color: Theme.of(context).colorScheme.primary), + title: Text(tr.supportDevelopment), + subtitle: Text(tr.supportDevelopmentSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () async => showDialog( + context: context, + builder: (_) => const SupportDevelopmentDialog(), + ), + ), + SectionListTile( + leading: const Icon(Icons.home_max_outlined), + title: Text(tr.forumHomepage), + subtitle: const Text(baseUrl), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + await launchUrl(Uri.parse(baseUrl), mode: LaunchMode.externalApplication); + }, + ), + SectionListTile( + leading: const Icon(Icons.home_outlined), + title: Text(tr.homepage), + subtitle: const Text('https://github.com/Carinoasd/tsdm_client'), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + await launchUrl( + Uri.parse('https://github.com/Carinoasd/tsdm_client'), + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), + const SizedBox(height: appSurfaceGap), + AppTileGroup( + title: tr.buildSection, + icon: Icons.info_outline, + children: [ + SectionListTile( + leading: const Icon(Icons.app_shortcut_outlined), + title: Text(tr.packageName), + subtitle: const Text('com.tsdm.tsdm_client'), + ), + SectionListTile( + leading: const Icon(Icons.terminal_outlined), + title: Text(tr.version), + subtitle: const Text(appFullVersion), + ), + SectionListTile( + leading: const FlutterLogo(), + title: Text(tr.flutterVersion), + subtitle: const Text('$flutterVersion ($flutterChannel) - $flutterFrameworkRevision'), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + await launchUrl(Uri.parse('https://flutter.dev/'), mode: LaunchMode.externalApplication); + }, + ), + SectionListTile( + leading: SvgPicture.asset(assetDartLogoPath, width: 22, height: 22), + title: Text(tr.dartVersion), + subtitle: const Text(dartVersion), + trailing: const Icon(Icons.open_in_new), + onTap: () async { + await launchUrl(Uri.parse('https://dart.dev/'), mode: LaunchMode.externalApplication); + }, + ), + SectionListTile( + leading: const Icon(Icons.balance_outlined), + title: Text(tr.license), + subtitle: const Text('MIT license'), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.license), + ), + ], + ), + ], ), - SectionListTile( - leading: const Icon(Icons.app_shortcut_outlined), - title: Text(tr.packageName), - subtitle: const Text('com.tsdm.tsdm_client'), - ), - SectionListTile( - leading: const Icon(Icons.terminal_outlined), - title: Text(tr.version), - subtitle: const Text(appFullVersion), - ), - SectionListTile( - leading: const Icon(Icons.home_max_outlined), - title: Text(context.t.aboutPage.forumHomepage), - subtitle: const Text(baseUrl), - onTap: () async { - await launchUrl(Uri.parse(baseUrl), mode: LaunchMode.externalApplication); - }, - ), - SectionListTile( - leading: const Icon(Icons.home_outlined), - title: Text(context.t.aboutPage.homepage), - subtitle: const Text('https://github.com/Carinoasd/tsdm_client'), - onTap: () async { - await launchUrl( - Uri.parse('https://github.com/Carinoasd/tsdm_client'), - mode: LaunchMode.externalApplication, - ); - }, - ), - SectionListTile( - leading: const Icon(Icons.favorite_border), - title: Text(tr.supportDevelopment), - subtitle: Text(tr.supportDevelopmentSubtitle), - onTap: () async => showDialog( - context: context, - builder: (_) => const SupportDevelopmentDialog(), - ), - ), - SectionListTile( - leading: const FlutterLogo(), - title: Text(context.t.aboutPage.flutterVersion), - subtitle: const Text('$flutterVersion ($flutterChannel) - $flutterFrameworkRevision'), - onTap: () async { - await launchUrl(Uri.parse('https://flutter.dev/'), mode: LaunchMode.externalApplication); - }, - ), - SectionListTile( - leading: SvgPicture.asset(assetDartLogoPath, width: 22, height: 22), - title: Text(context.t.aboutPage.dartVersion), - subtitle: const Text(dartVersion), - onTap: () async { - await launchUrl(Uri.parse('https://dart.dev/'), mode: LaunchMode.externalApplication); - }, - ), - SectionListTile( - leading: const Icon(Icons.balance_outlined), - title: Text(context.t.aboutPage.license), - subtitle: const Text('MIT license'), - onTap: () async => context.pushNamed(ScreenPaths.license), - ), - ], + ), ), ); } diff --git a/lib/features/settings/view/debug_log_page.dart b/lib/features/settings/view/debug_log_page.dart index 173d4469c..26a721f5f 100644 --- a/lib/features/settings/view/debug_log_page.dart +++ b/lib/features/settings/view/debug_log_page.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:path/path.dart' as path; import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/extensions/date_time.dart'; import 'package:tsdm_client/extensions/string.dart'; @@ -16,6 +17,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/log_redaction.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Colors of the talker screens (log list, the nested "Talker Monitor" page and their sheets) taken from [theme]. @@ -96,23 +98,61 @@ class DebugHistoricalLogPage extends StatelessWidget with LoggerMixin { final body = FutureBuilder( future: _loadLogFiles(), builder: (context, snapshot) { + // The error first: a failed future has no data and used to spin forever. + if (snapshot.hasError) { + error('failed to load logs: ${snapshot.error}'); + return AppStateView( + icon: Icons.error_outline, + error: true, + message: '${context.t.general.failedToLoad}: ${snapshot.error}', + ); + } + if (!snapshot.hasData) { return const CenteredCircularIndicator(); } - if (snapshot.hasError) { - error('failed to load logs: ${snapshot.error}'); - return Center(child: Text('${context.t.general.failedToLoad}: ${snapshot.error}')); + // Newest day first. + final logFiles = snapshot.data!..sort((a, b) => b.time.compareTo(a.time)); + if (logFiles.isEmpty) { + return AppStateView(icon: Icons.history_toggle_off_outlined, message: context.t.general.noData); } - final logFiles = snapshot.data!; - - return ListView.builder( - padding: context.safePadding(), - itemCount: logFiles.length, - itemBuilder: (context, idx) => ListTile( - title: Text(logFiles[idx].time.yyyyMMDD()), - onTap: () async => context.pushNamed(ScreenPaths.debugHistoricalLogDetail, extra: logFiles[idx]), + return AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView.separated( + padding: padding.copyWith(top: 12).add(context.safePadding()), + itemCount: logFiles.length, + separatorBuilder: (_, _) => appListSeparator, + itemBuilder: (context, idx) => AppSurface( + onTap: () async => context.pushNamed(ScreenPaths.debugHistoricalLogDetail, extra: logFiles[idx]), + child: Row( + children: [ + const AppIconTile(Icons.description_outlined), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + logFiles[idx].time.yyyyMMDD(), + style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + path.basename(logFiles[idx].file.path), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.outline), + ), + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), ), ); }, @@ -149,19 +189,34 @@ class _DebugHistoricalLogDetailPageState extends State with SingleTicker ), ], ), - body: TabBarView( - controller: tabController, - children: const [ - SingleChildScrollView(child: _HtmlFragment()), - _SampleThreadV2Page(), - ], + body: SafeArea( + top: false, + child: TabBarView( + controller: tabController, + children: const [ + // The rendered blocks inside a reading width surface, like a floor of a thread. + SingleChildScrollView( + padding: edgeInsetsL12T12R12B12, + child: AppContentWidth( + maxWidth: appReadingMaxWidth, + child: AppSurface(child: _HtmlFragment()), + ), + ), + _SampleThreadV2Page(), + ], + ), ), ); } @@ -269,74 +280,80 @@ class _SampleThreadV2PageState extends State<_SampleThreadV2Page> { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Form( - key: formKey, - child: Column( - children: [ - TextFormField( - controller: tidController, - decoration: const InputDecoration(labelText: 'Thread id'), - validator: (v) => - v == null || v.isEmpty || int.tryParse(v) == null || int.parse(v) <= 0 ? 'invalid thread id' : null, - ), - sizedBoxW8H8, - TextFormField( - controller: pageController, - decoration: const InputDecoration(labelText: 'Page number'), - validator: (v) => v == null || v.isEmpty || int.tryParse(v) == null || int.parse(v) <= 0 - ? 'invalid page number' - : null, - ), - sizedBoxW8H8, - FilledButton( - child: const Text('Fetch page'), - onPressed: () { - if (!formKey.currentState!.validate()) { - return; - } + return Padding( + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Form( + key: formKey, + child: AppFormSection( + children: [ + TextFormField( + controller: tidController, + keyboardType: TextInputType.number, + decoration: appFieldDecoration(label: 'Thread id', icon: Icons.tag), + validator: (v) => v == null || v.isEmpty || int.tryParse(v) == null || int.parse(v) <= 0 + ? 'invalid thread id' + : null, + ), + TextFormField( + controller: pageController, + keyboardType: TextInputType.number, + decoration: appFieldDecoration(label: 'Page number', icon: Icons.menu_book_outlined), + validator: (v) => v == null || v.isEmpty || int.tryParse(v) == null || int.parse(v) <= 0 + ? 'invalid page number' + : null, + ), + FilledButton( + child: const Text('Fetch page'), + onPressed: () { + if (!formKey.currentState!.validate()) { + return; + } - setState(() { - tid = int.parse(tidController.text); - page = int.parse(pageController.text); - }); - }, - ), - ], + setState(() { + tid = int.parse(tidController.text); + page = int.parse(pageController.text); + }); + }, + ), + ], + ), ), - ), - sizedBoxW8H8, - Expanded( - child: FutureBuilder( - key: ValueKey('ThreadContent_$tid'), - future: _fetchThreadPage(), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const CenteredCircularIndicator(); - } + sizedBoxW8H8, + Expanded( + child: FutureBuilder( + key: ValueKey('ThreadContent_$tid'), + future: _fetchThreadPage(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const CenteredCircularIndicator(); + } - if (snapshot.hasError) { - return Center(child: Text('Failed (tid=$tid, page=$page): ${snapshot.error!}')); - } + if (snapshot.hasError) { + return Center(child: Text('Failed (tid=$tid, page=$page): ${snapshot.error!}')); + } - if (!snapshot.hasData) { - return const CenteredCircularIndicator(); - } + if (!snapshot.hasData) { + return const CenteredCircularIndicator(); + } - // Remove CR and LF - // The CR is useless and remove it is safe. - // The LF only follows "
" which is useless and can be safely removed, too. - final content = (snapshot.data!.data as String).replaceAll(RegExp('\u000a|\u000d'), ''); + // Remove CR and LF + // The CR is useless and remove it is safe. + // The LF only follows "
" which is useless and can be safely removed, too. + final content = (snapshot.data!.data as String).replaceAll(RegExp('\u000a|\u000d'), ''); - final x = jsonDecode(content) as Map; + final x = jsonDecode(content) as Map; - return SingleChildScrollView(child: Text(x.toString())); - }, + return SingleChildScrollView( + child: AppInsetBlock(outlined: true, child: SelectableText(x.toString())), + ); + }, + ), ), - ), - ], + ], + ), ); } } diff --git a/lib/features/settings/view/settings_page.dart b/lib/features/settings/view/settings_page.dart index fbd76d3d0..37d2543ed 100644 --- a/lib/features/settings/view/settings_page.dart +++ b/lib/features/settings/view/settings_page.dart @@ -38,6 +38,7 @@ import 'package:tsdm_client/features/settings/widgets/font_scale_dialog.dart'; import 'package:tsdm_client/features/settings/widgets/language_dialog.dart'; import 'package:tsdm_client/features/settings/widgets/proxy_settings_dialog.dart'; import 'package:tsdm_client/features/settings/widgets/select_thread_floor_interaction_mode_dialog.dart'; +import 'package:tsdm_client/features/settings/widgets/thread_content_scale_dialog.dart'; import 'package:tsdm_client/features/theme/cubit/theme_cubit.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; @@ -53,12 +54,17 @@ import 'package:tsdm_client/utils/show_bottom_sheet.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; import 'package:tsdm_client/utils/window_configs.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/color_palette.dart'; import 'package:tsdm_client/widgets/section_list_tile.dart'; import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; -import 'package:tsdm_client/widgets/section_title_text.dart'; import 'package:tsdm_client/widgets/shutdown.dart'; -import 'package:tsdm_client/widgets/tips.dart'; + +/// Window width from which the settings groups are laid out in two columns. +const _settingsTwoColumnWidth = 1200.0; + +/// Widest the two columns of settings grow together. +const _settingsTwoColumnMaxWidth = 1240.0; /// Settings page of the app. class SettingsPage extends StatefulWidget { @@ -94,8 +100,9 @@ class _SettingsPageState extends State with WidgetsBindingObserver bottomBar: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - child: Text(context.t.general.reset), + TextButton.icon( + icon: const Icon(Icons.restart_alt_outlined), + label: Text(context.t.general.reset), onPressed: () async => context.pop((null, true)), ), ], @@ -103,19 +110,76 @@ class _SettingsPageState extends State with WidgetsBindingObserver ); } - List _buildAccountSection(BuildContext context, SettingsState state) { + /// Value of a row shown at its end, in the secondary color, never wider than a third of the row. + Widget _trailingValue(BuildContext context, String value) => ConstrainedBox( + constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width / 3), + child: Text( + value, + textAlign: TextAlign.end, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), + ), + ); + + Widget _buildAccountSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.accountSection; - return [ - SectionTitleText(tr.title), - SectionListTile( - leading: const Icon(Icons.account_circle_outlined), - title: Text(tr.mgmt), - onTap: () async => context.pushNamed(ScreenPaths.manageAccount), + return AppTileGroup( + title: tr.title, + icon: Icons.manage_accounts_outlined, + children: [ + SectionListTile( + leading: const Icon(Icons.account_circle_outlined), + title: Text(tr.mgmt), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.manageAccount), + ), + ], + ); + } + + Widget _buildThemeModeTile(BuildContext context, int themeModeIndex) { + final tr = context.t.settingsPage.appearanceSection; + // The switch sits at the end of the row, in one line with the title (feedback 111: below the text it left the + // right side of the row empty). Icons only: three labels do not fit a phone at a large text scale; each segment + // has a tooltip. + return SectionListTile( + leading: const Icon(Icons.contrast_outlined), + title: Text(tr.themeMode.title), + subtitle: Text([tr.themeMode.system, tr.themeMode.light, tr.themeMode.dark][themeModeIndex]), + trailing: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + ButtonSegment( + value: ThemeMode.light.index, + icon: const Icon(Icons.light_mode_outlined), + tooltip: tr.themeMode.light, + ), + ButtonSegment( + value: ThemeMode.system.index, + icon: const Icon(Icons.auto_mode_outlined), + tooltip: tr.themeMode.system, + ), + ButtonSegment( + value: ThemeMode.dark.index, + icon: const Icon(Icons.dark_mode_outlined), + tooltip: tr.themeMode.dark, + ), + ], + selected: {themeModeIndex}, + onSelectionChanged: (selection) { + final themeIndex = selection.first; + context.read().setThemeModeIndex(themeIndex); + context.read().add(SettingsValueChanged(SettingsKeys.themeMode, themeIndex)); + }, + ), ), - ]; + ); } - List _buildAppearanceSection(BuildContext context, SettingsState state) { + Widget _buildAppearanceSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.appearanceSection; final settingsLocale = state.settingsMap.locale; final locale = AppLocale.values.firstWhereOrNull((v) => v.languageTag == settingsLocale); @@ -124,202 +188,218 @@ class _SettingsPageState extends State with WidgetsBindingObserver final showForumCardShortcut = state.settingsMap.showShortcutInForumCard; final accentColor = state.settingsMap.accentColor; final accentColorFollowSystem = state.settingsMap.accentColorFollowSystem; - final showUnreadInfoHint = state.settingsMap.showUnreadInfoHint; - final showUnreadNoticeBadge = state.settingsMap.showUnreadNoticeBadge; - final showUnreadPersonalMessageBadge = state.settingsMap.showUnreadPersonalMessageBadge; - final showUnreadBroadcastMessageBadge = state.settingsMap.showUnreadBroadcastMessageBadge; final fontFamily = state.settingsMap.fontFamily; final textScaleFactor = state.settingsMap.textScaleFactor; + final threadContentScale = state.settingsMap.threadContentScale; - return [ - SectionTitleText(tr.title), - SectionListTile( - leading: const Icon(Icons.contrast_outlined), - title: Text(tr.themeMode.title), - subtitle: Text([tr.themeMode.system, tr.themeMode.light, tr.themeMode.dark][themeModeIndex]), - trailing: ToggleButtons( - isSelected: [ - themeModeIndex == ThemeMode.light.index, - themeModeIndex == ThemeMode.system.index, - themeModeIndex == ThemeMode.dark.index, - ], - children: const [ - Icon(Icons.light_mode_outlined), - Icon(Icons.auto_mode_outlined), - Icon(Icons.dark_mode_outlined), - ], - onPressed: (index) async { - var themeIndex = 0; - switch (index) { - case 0: themeIndex = 1; - case 1: themeIndex = 0; - case 2: themeIndex = 2; + return AppTileGroup( + title: tr.title, + icon: Icons.palette_outlined, + children: [ + _buildThemeModeTile(context, themeModeIndex), + SectionListTile( + leading: const Icon(Icons.translate_outlined), + title: Text(tr.languages.title), + subtitle: Text(localeName), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final localeGroup = await selectLanguageDialog(context, locale?.languageTag ?? ''); + if (localeGroup == null) return; + if (localeGroup.$2) { + await LocaleSettings.useDeviceLocale(); + await desktopUpdateWindowTitle(); + if (!context.mounted) return; + await getIt.get().setValue(SettingsKeys.locale, ''); + } else { + await LocaleSettings.setLocale(localeGroup.$1!); + await desktopUpdateWindowTitle(); + if (!context.mounted) return; + await getIt.get().setValue(SettingsKeys.locale, localeGroup.$1!.languageTag); + } + if (isAndroid && context.mounted) { + unawaited(getIt.get().applySettings(getIt.get())); } - context.read().setThemeModeIndex(themeIndex); - context.read().add(SettingsValueChanged(SettingsKeys.themeMode, themeIndex)); }, ), - ), - SectionListTile( - leading: const Icon(Icons.translate_outlined), - title: Text(tr.languages.title), - subtitle: Text(localeName), - onTap: () async { - final localeGroup = await selectLanguageDialog(context, locale?.languageTag ?? ''); - if (localeGroup == null) return; - if (localeGroup.$2) { - await LocaleSettings.useDeviceLocale(); - await desktopUpdateWindowTitle(); - if (!context.mounted) return; - await getIt.get().setValue(SettingsKeys.locale, ''); - } else { - await LocaleSettings.setLocale(localeGroup.$1!); - await desktopUpdateWindowTitle(); - if (!context.mounted) return; - await getIt.get().setValue(SettingsKeys.locale, localeGroup.$1!.languageTag); - } - if (isAndroid && context.mounted) { - unawaited(getIt.get().applySettings(getIt.get())); - } - }, - ), - SectionSwitchListTile( - secondary: const Icon(Icons.shortcut_outlined), - title: Text(tr.showShortcutInForumCard.title), - subtitle: Text(tr.showShortcutInForumCard.detail), - value: showForumCardShortcut, - onChanged: (v) async => context.read().add(SettingsValueChanged(SettingsKeys.showShortcutInForumCard, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.border_color_outlined), - title: Text(tr.colorSchemeFollowSystem.title), - subtitle: Text(tr.colorSchemeFollowSystem.detail), - value: accentColorFollowSystem, - onChanged: (v) async { - context.read().add(SettingsValueChanged(SettingsKeys.accentColorFollowSystem, v)); - if (v) { - await SystemTheme.accentColor.load(); + SectionSwitchListTile( + secondary: const Icon(Icons.border_color_outlined), + title: Text(tr.colorSchemeFollowSystem.title), + subtitle: Text(tr.colorSchemeFollowSystem.detail), + value: accentColorFollowSystem, + onChanged: (v) async { + context.read().add(SettingsValueChanged(SettingsKeys.accentColorFollowSystem, v)); + if (v) { + await SystemTheme.accentColor.load(); + if (!context.mounted) return; + final systemColor = SystemTheme.accentColor.accent; + context.read().setAccentColor(systemColor); + } else { + context.read().setAccentColor( + Color(accentColor >= 0 ? accentColor : SettingsKeys.accentColor.defaultValue), + ); + } + }, + ), + SectionListTile( + enabled: !accentColorFollowSystem, + leading: const Icon(Icons.color_lens_outlined), + title: Text(tr.colorScheme.title), + subtitle: accentColorFollowSystem ? Text(tr.colorScheme.overrideWithSystem) : null, + trailing: accentColor < 0 || accentColorFollowSystem ? null : ColorPalette(color: Color(accentColor)), + onTap: () async { + final color = await _showAccentColorPickerDialog(context); + if (color == null) return; if (!context.mounted) return; - final systemColor = SystemTheme.accentColor.accent; - context.read().setAccentColor(systemColor); - } else { - context.read().setAccentColor( - Color(accentColor >= 0 ? accentColor : SettingsKeys.accentColor.defaultValue), + if (color.$2) { + context.read().setAccentColor(Color(SettingsKeys.accentColor.defaultValue)); + context.read().add( + SettingsValueChanged(SettingsKeys.accentColor, SettingsKeys.accentColor.defaultValue), + ); + return; + } + context.read().setAccentColor(color.$1!); + context.read().add(SettingsValueChanged(SettingsKeys.accentColor, color.$1!.valueA)); + }, + ), + SectionListTile( + leading: const Icon(Icons.font_download_outlined), + title: Text(tr.fontFamily.title), + subtitle: fontFamily.isEmpty ? null : Text(fontFamily), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final selectedFont = await showDialog( + context: context, + builder: (_) => RootPage(DialogPaths.fontPicker, FontFamilyDialog(fontFamily)), ); - } - }, - ), - SectionListTile( - enabled: !accentColorFollowSystem, - leading: const Icon(Icons.color_lens_outlined), - title: Text(tr.colorScheme.title), - subtitle: accentColorFollowSystem ? Text(tr.colorScheme.overrideWithSystem) : null, - trailing: accentColor < 0 || accentColorFollowSystem ? null : ColorPalette(color: Color(accentColor)), - onTap: () async { - final color = await _showAccentColorPickerDialog(context); - if (color == null) return; - if (!context.mounted) return; - if (color.$2) { - context.read().setAccentColor(Color(SettingsKeys.accentColor.defaultValue)); - context.read().add(SettingsValueChanged(SettingsKeys.accentColor, SettingsKeys.accentColor.defaultValue)); - return; - } - context.read().setAccentColor(color.$1!); - context.read().add(SettingsValueChanged(SettingsKeys.accentColor, color.$1!.valueA)); - }, - ), - SectionSwitchListTile( - secondary: const Icon(Icons.notifications_outlined), - title: Text(tr.showUnreadInfoHint.title), - subtitle: Text(tr.showUnreadInfoHint.detail), - value: showUnreadInfoHint, - onChanged: (v) async => context.read().add(SettingsValueChanged(SettingsKeys.showUnreadInfoHint, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.notifications_paused_outlined), - title: Text(tr.unreadNoticeBadge), - value: showUnreadNoticeBadge, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.showUnreadNoticeBadge, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.notifications_active_outlined), - title: Text(tr.unreadPersonalMessageBadge), - value: showUnreadPersonalMessageBadge, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.showUnreadPersonalMessageBadge, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.notification_important_outlined), - title: Text(tr.unreadBroadcastMessageBadge), - value: showUnreadBroadcastMessageBadge, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.showUnreadBroadcastMessageBadge, v)), - ), - Padding(padding: edgeInsetsT4B4, child: Tips(tr.unreadBadgeLimitation)), - SectionListTile( - leading: const Icon(Icons.article_outlined), - title: Text(tr.threadCard.title), - subtitle: Text(tr.threadCard.detail), - onTap: () => context.pushNamed(ScreenPaths.settingsThreadAppearance.path), - ), - SectionListTile( - leading: const Icon(Icons.font_download_outlined), - title: Text(tr.fontFamily.title), - subtitle: fontFamily.isEmpty ? null : Text(fontFamily), - onTap: () async { - final selectedFont = await showDialog( - context: context, - builder: (_) => RootPage(DialogPaths.fontPicker, FontFamilyDialog(fontFamily)), - ); - if (selectedFont == null || !context.mounted) return; - context.read().setFontFamily(selectedFont); - context.read().add(SettingsValueChanged(SettingsKeys.fontFamily, selectedFont)); - }, - ), - SectionListTile( - leading: const Icon(Icons.text_increase_outlined), - title: Text(tr.textScaleFactor.title), - onTap: () async { - final selectedScale = await showDialog( - context: context, - builder: (_) => RootPage(DialogPaths.textScalePicker, TextScaleDialog(textScaleFactor)), - ); - if (!context.mounted || selectedScale == null) return; - context.read().add(SettingsValueChanged(SettingsKeys.textScaleFactor, selectedScale)); - }, - ), - ]; + if (selectedFont == null || !context.mounted) return; + context.read().setFontFamily(selectedFont); + context.read().add(SettingsValueChanged(SettingsKeys.fontFamily, selectedFont)); + }, + ), + SectionListTile( + leading: const Icon(Icons.text_increase_outlined), + title: Text(tr.textScaleFactor.title), + trailing: _trailingValue(context, '${textScaleFactor.toStringAsFixed(2)}x'), + onTap: () async { + final selectedScale = await showDialog( + context: context, + builder: (_) => RootPage(DialogPaths.textScalePicker, TextScaleDialog(textScaleFactor)), + ); + if (!context.mounted || selectedScale == null) return; + context.read().add(SettingsValueChanged(SettingsKeys.textScaleFactor, selectedScale)); + }, + ), + SectionListTile( + leading: const Icon(Icons.format_size_outlined), + title: Text(tr.threadContentScale.title), + subtitle: Text(tr.threadContentScale.detail), + trailing: _trailingValue(context, '${threadContentScale.toStringAsFixed(1)}x'), + onTap: () async { + final selectedScale = await showDialog( + context: context, + builder: (_) => + RootPage(DialogPaths.threadContentScalePicker, ThreadContentScaleDialog(threadContentScale)), + ); + if (!context.mounted || selectedScale == null) return; + context.read().add(SettingsValueChanged(SettingsKeys.threadContentScale, selectedScale)); + }, + ), + SectionListTile( + leading: const Icon(Icons.article_outlined), + title: Text(tr.threadCard.title), + subtitle: Text(tr.threadCard.detail), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.pushNamed(ScreenPaths.settingsThreadAppearance.path), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.shortcut_outlined), + title: Text(tr.showShortcutInForumCard.title), + subtitle: Text(tr.showShortcutInForumCard.detail), + value: showForumCardShortcut, + onChanged: (v) async => + context.read().add(SettingsValueChanged(SettingsKeys.showShortcutInForumCard, v)), + ), + ], + ); + } + + /// Unread hints and badges, split from the appearance group so each group stays short. + Widget _buildUnreadSection(BuildContext context, SettingsState state) { + final tr = context.t.settingsPage.appearanceSection; + final settings = state.settingsMap; + return AppTileGroup( + title: tr.showUnreadInfoHint.title, + icon: Icons.mark_email_unread_outlined, + footer: AppNoticeBanner(message: tr.unreadBadgeLimitation), + children: [ + SectionSwitchListTile( + secondary: const Icon(Icons.notifications_outlined), + title: Text(tr.showUnreadInfoHint.title), + subtitle: Text(tr.showUnreadInfoHint.detail), + value: settings.showUnreadInfoHint, + onChanged: (v) async => + context.read().add(SettingsValueChanged(SettingsKeys.showUnreadInfoHint, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.notifications_paused_outlined), + title: Text(tr.unreadNoticeBadge), + value: settings.showUnreadNoticeBadge, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.showUnreadNoticeBadge, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.notifications_active_outlined), + title: Text(tr.unreadPersonalMessageBadge), + value: settings.showUnreadPersonalMessageBadge, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.showUnreadPersonalMessageBadge, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.notification_important_outlined), + title: Text(tr.unreadBroadcastMessageBadge), + value: settings.showUnreadBroadcastMessageBadge, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.showUnreadBroadcastMessageBadge, v)), + ), + ], + ); } - List _buildWindowSection(BuildContext context, SettingsState state) { + Widget _buildWindowSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.windowSection; final windowRememberSize = state.settingsMap.windowRememberSize; final windowRememberPosition = state.settingsMap.windowRememberPosition; final windowInCenter = state.settingsMap.windowInCenter; - return [ - SectionTitleText(tr.title), - SectionSwitchListTile( - secondary: const Icon(Icons.settings_overscan_outlined), - title: Text(tr.windowRememberSize.title), - subtitle: Text(tr.windowRememberSize.detail), - value: windowRememberSize, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.windowRememberSize, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.open_with_outlined), - title: Text(tr.windowRememberPosition.title), - subtitle: Text(tr.windowRememberPosition.detail), - value: windowRememberPosition, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.windowRememberPosition, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.filter_center_focus_outlined), - title: Text(tr.windowInCenter.title), - subtitle: Text(tr.windowInCenter.detail), - value: windowInCenter, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.windowInCenter, v)), - ), - Tips(tr.disableHint), - ]; + return AppTileGroup( + title: tr.title, + icon: Icons.desktop_windows_outlined, + footer: AppNoticeBanner(message: tr.disableHint, selectable: true), + children: [ + SectionSwitchListTile( + secondary: const Icon(Icons.settings_overscan_outlined), + title: Text(tr.windowRememberSize.title), + subtitle: Text(tr.windowRememberSize.detail), + value: windowRememberSize, + onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.windowRememberSize, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.open_with_outlined), + title: Text(tr.windowRememberPosition.title), + subtitle: Text(tr.windowRememberPosition.detail), + value: windowRememberPosition, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.windowRememberPosition, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.filter_center_focus_outlined), + title: Text(tr.windowInCenter.title), + subtitle: Text(tr.windowInCenter.detail), + value: windowInCenter, + onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.windowInCenter, v)), + ), + ], + ); } Future _applyBackgroundSyncSettings(BuildContext context) async { @@ -337,106 +417,124 @@ class _SettingsPageState extends State with WidgetsBindingObserver await _applyBackgroundSyncSettings(context); } - List _buildBehaviorSection(BuildContext context, SettingsState state) { + /// Reading and editing behavior: order, floor interaction, editor parser and the templates. + Widget _buildBehaviorSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.behaviorSection; final threadReverseOrder = state.settingsMap.threadReverseOrder; + final enableBBCodeParser = state.settingsMap.enableEditorBBCodeParser; + final threadFloorInteractionMode = state.settingsMap.threadFloorInteractionMode; + + return AppTileGroup( + title: tr.title, + icon: Icons.tune_outlined, + children: [ + SectionSwitchListTile( + secondary: const Icon(Icons.align_vertical_top_outlined), + title: Text(tr.threadReverseOrder.title), + subtitle: Text(tr.threadReverseOrder.detail), + value: threadReverseOrder, + onChanged: (v) async => + context.read().add(SettingsValueChanged(SettingsKeys.threadReverseOrder, v)), + ), + SectionListTile( + leading: const Icon(Icons.touch_app_outlined), + title: Text(tr.threadFloorInteractionMode.title), + subtitle: Text(tr.threadFloorInteractionMode.detail), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final result = await showSelectThreadFloorInteractionMode(context, threadFloorInteractionMode); + if (result == null) return; + if (!context.mounted) return; + context.read().add(SettingsValueChanged(SettingsKeys.threadFloorInteractionMode, result)); + }, + ), + SectionSwitchListTile( + secondary: const Icon(Icons.code_outlined), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded(child: Text(tr.editorBBCodeParser.title)), + sizedBoxW8H8, + IconButton( + icon: Icon(Icons.help_outline, color: Theme.of(context).colorScheme.secondary), + tooltip: tr.editorBBCodeParser.tip.title, + onPressed: () async => showMessageSingleButtonDialog( + context: context, + title: tr.editorBBCodeParser.tip.title, + message: tr.editorBBCodeParser.tip.detail, + ), + ), + ], + ), + value: enableBBCodeParser, + onChanged: (v) async => + context.read().add(SettingsValueChanged(SettingsKeys.enableEditorBBCodeParser, v)), + ), + SectionListTile( + leading: const Icon(Icons.star_rate_outlined), + title: Text(context.t.fastRateTemplate.title), + subtitle: Text(context.t.fastRateTemplate.details), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.fastRateTemplate, pathParameters: {'pick': 'false'}), + ), + SectionListTile( + leading: const Icon(Icons.quickreply_outlined), + title: Text(context.t.fastReplyTemplate.title), + subtitle: Text(context.t.fastReplyTemplate.details), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.fastReplyTemplate, pathParameters: {'pick': 'false'}), + ), + ], + ); + } + + /// Notice sync: interval, Android permissions and the background service. + Widget _buildNoticeSyncSection(BuildContext context, SettingsState state) { + final tr = context.t.settingsPage.behaviorSection; final autoSyncNoticeSeconds = state.settingsMap.autoSyncNoticeSeconds; Duration? autoSyncNoticeDuration; if (autoSyncNoticeSeconds > 0) { autoSyncNoticeDuration = Duration(seconds: autoSyncNoticeSeconds); } - final enableBBCodeParser = state.settingsMap.enableEditorBBCodeParser; - final threadFloorInteractionMode = state.settingsMap.threadFloorInteractionMode; - return [ - SectionTitleText(tr.title), - SectionSwitchListTile( - secondary: const Icon(Icons.align_vertical_top_outlined), - title: Text(tr.threadReverseOrder.title), - subtitle: Text(tr.threadReverseOrder.detail), - value: threadReverseOrder, - onChanged: (v) async => context.read().add(SettingsValueChanged(SettingsKeys.threadReverseOrder, v)), - ), - SectionListTile( - leading: const Icon(Icons.sync_outlined), - title: Text(tr.autoSyncNotice.title), - subtitle: Text(tr.autoSyncNotice.detail), - trailing: Text( - autoSyncNoticeDuration?.readable(context) ?? context.t.general.never, - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), - ), - onTap: () async { - final seconds = await showDialog( - context: context, - builder: (_) => RootPage(DialogPaths.selectAutoSyncDuration, AutoSyncNoticeDialog(autoSyncNoticeSeconds)), - ); - if (seconds == null || !context.mounted) return; - if (seconds > 0) { - context.read().start(Duration(seconds: seconds)); - unawaited(_permissionCubit.requestNotification(openSettingsWhenPermanentlyDenied: false)); - } else { - context.read().stop(); - } - await getIt.get().setValue(SettingsKeys.autoSyncNoticeSeconds, seconds); - if (isAndroid && context.mounted) { - await _applyBackgroundSyncSettings(context); - } - }, - ), - if (isAndroid) const AndroidPermissionTiles(), - if (isAndroid) - SectionSwitchListTile( - secondary: const Icon(Icons.cloud_sync_outlined), - title: Text(tr.backgroundMessageService.title), - subtitle: Text(tr.backgroundMessageService.detail), - value: state.settingsMap.enableBackgroundMessageService, - onChanged: (v) async => _toggleBackgroundSync(context, enable: v), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.code_outlined), - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded(child: Text(tr.editorBBCodeParser.title)), - sizedBoxW8H8, - IconButton( - icon: Icon(Icons.help_outline, color: Theme.of(context).colorScheme.secondary), - tooltip: tr.editorBBCodeParser.tip.title, - onPressed: () async => showMessageSingleButtonDialog( - context: context, - title: tr.editorBBCodeParser.tip.title, - message: tr.editorBBCodeParser.tip.detail, - ), - ), - ], + return AppTileGroup( + title: tr.autoSyncNotice.title, + icon: Icons.sync_outlined, + children: [ + SectionListTile( + leading: const Icon(Icons.sync_outlined), + title: Text(tr.autoSyncNotice.title), + subtitle: Text(tr.autoSyncNotice.detail), + trailing: _trailingValue(context, autoSyncNoticeDuration?.readable(context) ?? context.t.general.never), + onTap: () async { + final seconds = await showDialog( + context: context, + builder: (_) => RootPage(DialogPaths.selectAutoSyncDuration, AutoSyncNoticeDialog(autoSyncNoticeSeconds)), + ); + if (seconds == null || !context.mounted) return; + if (seconds > 0) { + context.read().start(Duration(seconds: seconds)); + unawaited(_permissionCubit.requestNotification(openSettingsWhenPermanentlyDenied: false)); + } else { + context.read().stop(); + } + await getIt.get().setValue(SettingsKeys.autoSyncNoticeSeconds, seconds); + if (isAndroid && context.mounted) { + await _applyBackgroundSyncSettings(context); + } + }, ), - value: enableBBCodeParser, - onChanged: (v) async => context.read().add(SettingsValueChanged(SettingsKeys.enableEditorBBCodeParser, v)), - ), - SectionListTile( - leading: const Icon(Icons.star_rate_outlined), - title: Text(context.t.fastRateTemplate.title), - subtitle: Text(context.t.fastRateTemplate.details), - onTap: () async => context.pushNamed(ScreenPaths.fastRateTemplate, pathParameters: {'pick': 'false'}), - ), - SectionListTile( - leading: const Icon(Icons.quickreply_outlined), - title: Text(context.t.fastReplyTemplate.title), - subtitle: Text(context.t.fastReplyTemplate.details), - onTap: () async => context.pushNamed(ScreenPaths.fastReplyTemplate, pathParameters: {'pick': 'false'}), - ), - SectionListTile( - leading: const Icon(Icons.touch_app_outlined), - title: Text(context.t.settingsPage.behaviorSection.threadFloorInteractionMode.title), - subtitle: Text(context.t.settingsPage.behaviorSection.threadFloorInteractionMode.detail), - onTap: () async { - final result = await showSelectThreadFloorInteractionMode(context, threadFloorInteractionMode); - if (result == null) return; - if (!context.mounted) return; - context.read().add(SettingsValueChanged(SettingsKeys.threadFloorInteractionMode, result)); - }, - ), - ]; + if (isAndroid) const AndroidPermissionTiles(), + if (isAndroid) + SectionSwitchListTile( + secondary: const Icon(Icons.cloud_sync_outlined), + title: Text(tr.backgroundMessageService.title), + subtitle: Text(tr.backgroundMessageService.detail), + value: state.settingsMap.enableBackgroundMessageService, + onChanged: (v) async => _toggleBackgroundSync(context, enable: v), + ), + ], + ); } Future _showSetCheckinFeelingDialog(BuildContext context, String defaultFeeling) async { @@ -453,54 +551,59 @@ class _SettingsPageState extends State with WidgetsBindingObserver ); } - List _buildCheckinSection(BuildContext context, SettingsState state) { + Widget _buildCheckinSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.checkinSection; final checkinFeeling = state.settingsMap.checkinFeeling; final checkinMessage = state.settingsMap.checkinMessage; final autoCheckin = state.settingsMap.autoCheckin; - return [ - SectionTitleText(tr.title), - SectionListTile( - leading: const Icon(Icons.emoji_emotions_outlined), - title: Text(tr.feeling), - subtitle: Text(CheckinFeeling.from(checkinFeeling).translate(context)), - onTap: () async { - final result = await _showSetCheckinFeelingDialog(context, checkinFeeling); - if (result == null) return; - if (!context.mounted) return; - context.read().add(SettingsValueChanged(SettingsKeys.checkinFeeling, result)); - }, - ), - SectionListTile( - leading: const Icon(Icons.textsms_outlined), - title: Text(tr.anythingToSay), - subtitle: Text(checkinMessage), - onTap: () async { - final result = await _showSetCheckinMessageDialog(context, checkinMessage); - if (result == null) return; - if (!context.mounted) return; - context.read().add(SettingsValueChanged(SettingsKeys.checkinMessage, result)); - }, - ), - SectionSwitchListTile( - secondary: Icon(MdiIcons.autoFix), - title: Text(tr.autoCheckin.title), - subtitle: Text(tr.autoCheckin.detail), - value: autoCheckin, - onChanged: (v) async => context.read().add(SettingsValueChanged(SettingsKeys.autoCheckin, v)), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.redeem_outlined), - title: Text(tr.autoDailyRedPacket.title), - subtitle: Text(tr.autoDailyRedPacket.detail), - value: state.settingsMap.autoDailyRedPacket, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.autoDailyRedPacket, v)), - ), - ]; + return AppTileGroup( + title: tr.title, + icon: Icons.event_available_outlined, + children: [ + SectionListTile( + leading: const Icon(Icons.emoji_emotions_outlined), + title: Text(tr.feeling), + subtitle: Text(CheckinFeeling.from(checkinFeeling).translate(context)), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final result = await _showSetCheckinFeelingDialog(context, checkinFeeling); + if (result == null) return; + if (!context.mounted) return; + context.read().add(SettingsValueChanged(SettingsKeys.checkinFeeling, result)); + }, + ), + SectionListTile( + leading: const Icon(Icons.textsms_outlined), + title: Text(tr.anythingToSay), + subtitle: Text(checkinMessage), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + final result = await _showSetCheckinMessageDialog(context, checkinMessage); + if (result == null) return; + if (!context.mounted) return; + context.read().add(SettingsValueChanged(SettingsKeys.checkinMessage, result)); + }, + ), + SectionSwitchListTile( + secondary: Icon(MdiIcons.autoFix), + title: Text(tr.autoCheckin.title), + subtitle: Text(tr.autoCheckin.detail), + value: autoCheckin, + onChanged: (v) async => context.read().add(SettingsValueChanged(SettingsKeys.autoCheckin, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.redeem_outlined), + title: Text(tr.autoDailyRedPacket.title), + subtitle: Text(tr.autoDailyRedPacket.detail), + value: state.settingsMap.autoDailyRedPacket, + onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.autoDailyRedPacket, v)), + ), + ], + ); } - List _buildStorageSection(BuildContext context, SettingsState state) { + Widget _buildStorageSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.storageSection; final enableAutoClearImageCache = state.settingsMap.enableAutoClearImageCache; final autoClearImageDurationSec = state.settingsMap.autoClearImageCacheDuration; @@ -509,38 +612,49 @@ class _SettingsPageState extends State with WidgetsBindingObserver autoClearImageDuration = Duration(seconds: autoClearImageDurationSec); } - return [ - SectionTitleText(tr.title), - SectionListTile( - leading: const Icon(Icons.cleaning_services_outlined), - title: Text(tr.clearCache), - onTap: () async => showClearCacheBottomSheet(context: context), - ), - SectionSwitchListTile( - secondary: const Icon(Icons.image_not_supported_outlined), - title: Text(tr.scheduledCleaning.title), - subtitle: Text(tr.scheduledCleaning.details), - value: enableAutoClearImageCache, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.enableAutoClearImageCache, v)), - ), - SectionListTile( - enabled: enableAutoClearImageCache, - leading: const Icon(Symbols.auto_timer_rounded), - title: Text(tr.scheduledCleaning.duration.title), - subtitle: autoClearImageDuration != null ? Text(autoClearImageDuration.readable(context)) : null, - onTap: () async { - final seconds = await showDialog( - context: context, - builder: (_) => RootPage(DialogPaths.autoClearImageCacheDuration, AutoClearImageCacheDurationDialog(autoClearImageDurationSec)), - ); - if (seconds == null || !context.mounted) return; - context.read().add(SettingsValueChanged(SettingsKeys.autoClearImageCacheDuration, seconds)); - }, - ), - ]; + return AppTileGroup( + title: tr.title, + icon: Icons.storage_outlined, + children: [ + SectionListTile( + leading: const Icon(Icons.cleaning_services_outlined), + title: Text(tr.clearCache), + trailing: const Icon(Icons.chevron_right), + onTap: () async => showClearCacheBottomSheet(context: context), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.image_not_supported_outlined), + title: Text(tr.scheduledCleaning.title), + subtitle: Text(tr.scheduledCleaning.details), + value: enableAutoClearImageCache, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.enableAutoClearImageCache, v)), + ), + SectionListTile( + enabled: enableAutoClearImageCache, + leading: const Icon(Symbols.auto_timer_rounded), + title: Text(tr.scheduledCleaning.duration.title), + trailing: autoClearImageDuration != null + ? _trailingValue(context, autoClearImageDuration.readable(context)) + : null, + onTap: () async { + final seconds = await showDialog( + context: context, + builder: (_) => RootPage( + DialogPaths.autoClearImageCacheDuration, + AutoClearImageCacheDurationDialog(autoClearImageDurationSec), + ), + ); + if (seconds == null || !context.mounted) return; + context.read().add(SettingsValueChanged(SettingsKeys.autoClearImageCacheDuration, seconds)); + }, + ), + ], + ); } - List _buildAdvanceSection(BuildContext context, SettingsState state) { + /// Network: debug showcase (debug builds) and the proxy. + Widget _buildAdvanceSection(BuildContext context, SettingsState state) { final netClientUseProxy = state.settingsMap.netClientUseProxy; final netClientProxy = state.settingsMap.netClientProxy; final useDetectedProxy = state.settingsMap.useDetectedProxyWhenStartup; @@ -554,86 +668,117 @@ class _SettingsPageState extends State with WidgetsBindingObserver final proxyAutomated = isAndroid || isMacOS || isIOS; final tr = context.t.settingsPage.advancedSection; - return [ - SectionTitleText(tr.title), - if (!kReleaseMode) - SectionListTile( - leading: const Icon(Icons.developer_mode_outlined), - title: const Text('DEBUG SHOWCASE'), - onTap: () async => Navigator.push(context, MaterialPageRoute(builder: (context) => const DebugShowcasePage())), - ), - if (proxyAutomated) + return AppTileGroup( + title: tr.title, + icon: Icons.settings_ethernet_outlined, + children: [ + if (!kReleaseMode) + SectionListTile( + leading: const Icon(Icons.developer_mode_outlined), + title: const Text('DEBUG SHOWCASE'), + trailing: const Icon(Icons.chevron_right), + onTap: () async => + Navigator.push(context, MaterialPageRoute(builder: (context) => const DebugShowcasePage())), + ), + if (proxyAutomated) + SectionListTile( + leading: Icon(MdiIcons.networkOutline), + title: Text(tr.useProxy), + subtitle: Text(tr.proxySettings.automatedOnPlatform), + enabled: false, + ) + else + SectionSwitchListTile( + secondary: Icon(MdiIcons.networkOutline), + title: Text(tr.useProxy), + value: netClientUseProxy, + onChanged: (v) { + context.read().add(SettingsValueChanged(SettingsKeys.netClientUseProxy, v)); + showSnackBar(context: context, message: context.t.general.affectAfterRestart); + }, + ), + if (!proxyAutomated) + SectionSwitchListTile( + secondary: const Icon(Symbols.network_manage), + title: Text(tr.proxySettings.useDetectProxy.title), + subtitle: Text(tr.proxySettings.useDetectProxy.detail), + value: useDetectedProxy, + onChanged: netClientUseProxy && !proxyAutomated + ? (v) async => context.read().add( + SettingsValueChanged(SettingsKeys.useDetectedProxyWhenStartup, v), + ) + : null, + ), + if (!proxyAutomated) + SectionListTile( + enabled: netClientUseProxy && !useDetectedProxy && !proxyAutomated, + leading: const Icon(Icons.network_locked_outlined), + title: Text(tr.proxySettings.title), + subtitle: host == null || port == null ? null : Text('$host:$port'), + trailing: const Icon(Icons.chevron_right), + onTap: () async => showDialog( + context: context, + builder: (context) => RootPage(DialogPaths.setupProxy, ProxySettingsDialog(host: host, port: port)), + barrierDismissible: false, + ), + ), + ], + ); + } + + /// Export and import of the app data; the texts about what the backup holds are kept as they are. + Widget _buildBackupSection(BuildContext context, SettingsState state) { + final tr = context.t.settingsPage.advancedSection; + return AppTileGroup( + title: tr.backupTitle, + icon: Icons.backup_outlined, + children: [ SectionListTile( - leading: Icon(MdiIcons.networkOutline), - title: Text(tr.useProxy), - subtitle: Text(tr.proxySettings.automatedOnPlatform), - enabled: false, - ) - else - SectionSwitchListTile( - secondary: Icon(MdiIcons.networkOutline), - title: Text(tr.useProxy), - value: netClientUseProxy, - onChanged: (v) { - context.read().add(SettingsValueChanged(SettingsKeys.netClientUseProxy, v)); - showSnackBar(context: context, message: context.t.general.affectAfterRestart); + leading: const Icon(Icons.download_outlined), + title: Text(tr.exportData), + subtitle: Text(tr.exportDataDetail), + onTap: () async { + final choice = await showExportBackupDialog(context); + if (choice == null || !context.mounted) return; + final data = await const BackupRepository().exportSanitized( + await databaseFile, + secretsPassword: choice.password, + ); + final stamp = DateTime.now().microsecondsSinceEpoch; + final name = choice.password == null + ? 'tsdm_client_data_$stamp.db' + : 'tsdm_client_data_${stamp}_accounts.db'; + if (isDesktop) { + final filePath = await FilePicker.platform.saveFile(dialogTitle: tr.exportData, fileName: name); + if (filePath == null) return; + await File(filePath).writeAsBytes(data, flush: true); + } else { + await FilePicker.platform.saveFile(dialogTitle: tr.exportData, fileName: name, bytes: data); + } }, ), - if (!proxyAutomated) - SectionSwitchListTile( - secondary: const Icon(Symbols.network_manage), - title: Text(tr.proxySettings.useDetectProxy.title), - subtitle: Text(tr.proxySettings.useDetectProxy.detail), - value: useDetectedProxy, - onChanged: netClientUseProxy && !proxyAutomated ? (v) async => context.read().add(SettingsValueChanged(SettingsKeys.useDetectedProxyWhenStartup, v)) : null, - ), - if (!proxyAutomated) SectionListTile( - enabled: netClientUseProxy && !useDetectedProxy && !proxyAutomated, - leading: const Icon(Icons.network_locked_outlined), - title: Text(tr.proxySettings.title), - onTap: () async => showDialog( - context: context, - builder: (context) => RootPage(DialogPaths.setupProxy, ProxySettingsDialog(host: host, port: port)), - barrierDismissible: false, - ), + leading: const Icon(Icons.upload_outlined), + title: Text(tr.importData.title), + onTap: () async { + final ok = await showQuestionDialog( + context: context, + title: tr.importData.title, + message: tr.importData.tip, + ); + if (ok != true || !context.mounted) return; + final files = await FilePicker.platform.pickFiles(dialogTitle: tr.importData.title); + if (files == null || !context.mounted) return; + final file = files.files.firstOrNull; + if (file == null || file.path == null) { + showSnackBar(context: context, message: tr.importData.invalidData); + return; + } + await _importBackup(context, File(file.path!)); + }, ), - SectionListTile( - leading: const Icon(Icons.download_outlined), - title: Text(tr.exportData), - subtitle: Text(tr.exportDataDetail), - onTap: () async { - final choice = await showExportBackupDialog(context); - if (choice == null || !context.mounted) return; - final data = await const BackupRepository().exportSanitized(await databaseFile, secretsPassword: choice.password); - final stamp = DateTime.now().microsecondsSinceEpoch; - final name = choice.password == null ? 'tsdm_client_data_$stamp.db' : 'tsdm_client_data_${stamp}_accounts.db'; - if (isDesktop) { - final filePath = await FilePicker.platform.saveFile(dialogTitle: tr.exportData, fileName: name); - if (filePath == null) return; - await File(filePath).writeAsBytes(data, flush: true); - } else { - await FilePicker.platform.saveFile(dialogTitle: tr.exportData, fileName: name, bytes: data); - } - }, - ), - SectionListTile( - leading: const Icon(Icons.upload_outlined), - title: Text(tr.importData.title), - onTap: () async { - final ok = await showQuestionDialog(context: context, title: tr.importData.title, message: tr.importData.tip); - if (ok != true || !context.mounted) return; - final files = await FilePicker.platform.pickFiles(dialogTitle: tr.importData.title); - if (files == null || !context.mounted) return; - final file = files.files.firstOrNull; - if (file == null || file.path == null) { - showSnackBar(context: context, message: tr.importData.invalidData); - return; - } - await _importBackup(context, File(file.path!)); - }, - ), - ]; + ], + ); } Future _importBackup(BuildContext context, File source) async { @@ -643,7 +788,11 @@ class _SettingsPageState extends State with WidgetsBindingObserver final check = await repository.validate(source, currentSchemaVersion: schemaVersion); if (!context.mounted) return; if (!check.ok) { - await showMessageSingleButtonDialog(context: context, title: tr.title, message: tr.invalidDetail(reason: _backupProblemText(context, check))); + await showMessageSingleButtonDialog( + context: context, + title: tr.title, + message: tr.invalidDetail(reason: _backupProblemText(context, check)), + ); return; } BackupSecretsPayload? secrets; @@ -658,7 +807,12 @@ class _SettingsPageState extends State with WidgetsBindingObserver BackupReplaceException? replaceFailure; try { await getIt.get().dispose(); - await repository.replaceDatabase(target: await databaseFile, source: source, currentSchemaVersion: schemaVersion, secrets: secrets); + await repository.replaceDatabase( + target: await databaseFile, + source: source, + currentSchemaVersion: schemaVersion, + secrets: secrets, + ); } on BackupInvalidException catch (e) { invalid = e.validation; } on BackupReplaceException catch (e) { @@ -710,73 +864,127 @@ class _SettingsPageState extends State with WidgetsBindingObserver }; } - List _buildDebugSection(BuildContext context, SettingsState state) { + Widget _buildDebugSection(BuildContext context, SettingsState state) { final enableDebugOperations = state.settingsMap.enableDebugOperations; final tr = context.t.settingsPage.debugSection; - return [ - SectionTitleText(tr.title), - ExpansionTile( - leading: const Icon(Icons.bug_report_outlined), - title: Text(tr.tip), - children: [ - SectionSwitchListTile( - title: Text(tr.enableDebugOperations.title), - subtitle: Text(tr.enableDebugOperations.detail), - value: enableDebugOperations, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.enableDebugOperations, v)), - ), - SectionListTile(title: Text(tr.viewLog.title), onTap: () async => context.pushNamed(ScreenPaths.debugLog)), - SectionListTile( - title: Text(tr.exportLog.title), - subtitle: _logExportPath == null ? null : Text(tr.exportLog.detail(path: _logExportPath!)), - onTap: () async { - final logData = talker.history.map((e) => redactSensitive(e.generateTextMessage())).join('\n'); - final outputFile = await FilePicker.platform.saveFile(fileName: 'log_${DateTime.now().millisecondsSinceEpoch}.txt', bytes: utf8.encode(logData)); - if (outputFile == null) { - setState(() => _logExportPath = null); - } else { - setState(() => _logExportPath = outputFile); - } - }, - ), - SectionListTile(title: Text(tr.viewHistoryLog.title), onTap: () async => context.pushNamed(ScreenPaths.debugHistoricalLog)), - SectionListTile( - title: Text(tr.copyDatabaseDir), - onTap: () async { - final path = (await databaseFile).parent.path; - if (!context.mounted) return; - await copyToClipboard(context, path); - }, - ), - if (isAndroid) + return AppTileGroup( + title: tr.title, + icon: Icons.bug_report_outlined, + children: [ + // Collapsed by default: these rows are only for troubleshooting. + ExpansionTile( + key: const ValueKey('settings-debug-expansion'), + // Same side padding as the section rows (SectionListTile): the theme's 10px put the warning icon 6px left + // of every other icon of the page (feedback 110). + tilePadding: edgeInsetsL16R16, + leading: const Icon(Icons.warning_amber_outlined), + title: Text(tr.tip), + shape: const Border(), + collapsedShape: const Border(), + childrenPadding: const EdgeInsets.only(bottom: 8), + children: [ + SectionSwitchListTile( + secondary: const Icon(Icons.developer_board_outlined), + title: Text(tr.enableDebugOperations.title), + subtitle: Text(tr.enableDebugOperations.detail), + value: enableDebugOperations, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.enableDebugOperations, v)), + ), SectionListTile( - title: Text(tr.testNotification.title), - subtitle: Text(tr.testNotification.detail), - onTap: () async => showLocalNotification( - context, - NotificationAutoSyncInfoNotice(msg: tr.testNotification.title, notice: 1, personalMessage: 0, broadcastMessage: 0, timestamp: DateTime.now().millisecondsSinceEpoch), - ), + leading: const Icon(Icons.receipt_long_outlined), + title: Text(tr.viewLog.title), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.debugLog), ), - ], - ), - ]; + SectionListTile( + leading: const Icon(Icons.file_download_outlined), + title: Text(tr.exportLog.title), + subtitle: _logExportPath == null ? null : Text(tr.exportLog.detail(path: _logExportPath!)), + onTap: () async { + final logData = talker.history.map((e) => redactSensitive(e.generateTextMessage())).join('\n'); + final outputFile = await FilePicker.platform.saveFile( + fileName: 'log_${DateTime.now().millisecondsSinceEpoch}.txt', + bytes: utf8.encode(logData), + ); + if (outputFile == null) { + setState(() => _logExportPath = null); + } else { + setState(() => _logExportPath = outputFile); + } + }, + ), + SectionListTile( + leading: const Icon(Icons.history_outlined), + title: Text(tr.viewHistoryLog.title), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.debugHistoricalLog), + ), + SectionListTile( + leading: const Icon(Icons.folder_copy_outlined), + title: Text(tr.copyDatabaseDir), + onTap: () async { + final path = (await databaseFile).parent.path; + if (!context.mounted) return; + await copyToClipboard(context, path); + }, + ), + if (isAndroid) + SectionListTile( + leading: const Icon(Icons.notification_add_outlined), + title: Text(tr.testNotification.title), + subtitle: Text(tr.testNotification.detail), + onTap: () async => showLocalNotification( + context, + NotificationAutoSyncInfoNotice( + msg: tr.testNotification.title, + notice: 1, + personalMessage: 0, + broadcastMessage: 0, + timestamp: DateTime.now().millisecondsSinceEpoch, + ), + ), + ), + ], + ), + ], + ); } - List _buildOtherSection(BuildContext context, SettingsState state) { + Widget _buildOtherSection(BuildContext context, SettingsState state) { final tr = context.t.settingsPage.othersSection; final enableUpdateCheckOnStartup = state.settingsMap.enableUpdateCheckOnStartup; - return [ - SectionTitleText(tr.title), - SectionListTile(leading: const Icon(Icons.info_outline), title: Text(tr.about), onTap: () async => context.pushNamed(ScreenPaths.about)), - SectionSwitchListTile( - secondary: const Icon(Icons.cloud_done_outlined), - title: Text(tr.updateCheckOnStartup), - value: enableUpdateCheckOnStartup, - onChanged: (v) => context.read().add(SettingsValueChanged(SettingsKeys.enableUpdateCheckOnStartup, v)), - ), - SectionListTile(leading: const Icon(Icons.new_releases_outlined), title: Text(tr.update), onTap: () async => context.pushNamed(ScreenPaths.update)), - SectionListTile(leading: const Icon(Icons.history_outlined), title: Text(tr.changelog), onTap: () async => context.pushNamed(ScreenPaths.localChangelog)), - ]; + return AppTileGroup( + title: tr.title, + icon: Icons.more_horiz_outlined, + children: [ + SectionListTile( + leading: const Icon(Icons.info_outline), + title: Text(tr.about), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.about), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.cloud_done_outlined), + title: Text(tr.updateCheckOnStartup), + value: enableUpdateCheckOnStartup, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.enableUpdateCheckOnStartup, v)), + ), + SectionListTile( + leading: const Icon(Icons.new_releases_outlined), + title: Text(tr.update), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.update), + ), + SectionListTile( + leading: const Icon(Icons.history_outlined), + title: Text(tr.changelog), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.pushNamed(ScreenPaths.localChangelog), + ), + ], + ); } @override @@ -809,10 +1017,35 @@ class _SettingsPageState extends State with WidgetsBindingObserver } } + /// Stacks [groups] with the gap of the lists. + Widget _column(List groups) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < groups.length; i++) ...[if (i > 0) const SizedBox(height: appSurfaceGap), groups[i]], + ], + ); + @override Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { + // Two columns on wide windows: looks and account on the start side, behavior and data on the end side. + final start = [ + _buildAccountSection(context, state), + _buildAppearanceSection(context, state), + _buildUnreadSection(context, state), + if (isDesktop) _buildWindowSection(context, state), + _buildCheckinSection(context, state), + ]; + final end = [ + _buildBehaviorSection(context, state), + _buildNoticeSyncSection(context, state), + _buildStorageSection(context, state), + _buildAdvanceSection(context, state), + _buildBackupSection(context, state), + _buildDebugSection(context, state), + _buildOtherSection(context, state), + ]; return BlocProvider.value( value: _permissionCubit, child: Scaffold( @@ -820,19 +1053,31 @@ class _SettingsPageState extends State with WidgetsBindingObserver body: SafeArea( left: false, top: false, - child: ListView( - controller: scrollController, - children: [ - ..._buildAccountSection(context, state), - ..._buildAppearanceSection(context, state), - if (isDesktop) ..._buildWindowSection(context, state), - ..._buildBehaviorSection(context, state), - ..._buildCheckinSection(context, state), - ..._buildStorageSection(context, state), - ..._buildAdvanceSection(context, state), - ..._buildDebugSection(context, state), - ..._buildOtherSection(context, state), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final twoColumns = constraints.maxWidth >= _settingsTwoColumnWidth; + final padding = appCenteredPadding( + constraints.maxWidth, + maxWidth: twoColumns ? _settingsTwoColumnMaxWidth : appFormMaxWidth, + ); + return ListView( + controller: scrollController, + padding: padding.copyWith(top: 12, bottom: 24), + children: [ + if (twoColumns) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(start)), + const SizedBox(width: appSurfaceGap), + Expanded(child: _column(end)), + ], + ) + else + _column([...start, ...end]), + ], + ); + }, ), ), ), diff --git a/lib/features/settings/view/thread_card_appearance.dart b/lib/features/settings/view/thread_card_appearance.dart index fc71dbc7b..84fcc7ec8 100644 --- a/lib/features/settings/view/thread_card_appearance.dart +++ b/lib/features/settings/view/thread_card_appearance.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:tsdm_client/constants/layout.dart'; -import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/thread_card/thread_card.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; @@ -24,135 +24,166 @@ class _SettingsThreadCardAppearancePageState extends State showHelpDialog(BuildContext context) async { final tr = context.t.settingsPage.appearanceSection.threadCard.attrs; - final contents = []; - - for (final threadState in ThreadStateModel.values) { - switch (threadState) { - case ThreadStateModel.closed: - contents.add( - ListTile(leading: Icon(threadState.icon), title: Text(tr.closed.title), subtitle: Text(tr.closed.detail)), - ); - case ThreadStateModel.upVoted: - contents.add( - ListTile(leading: Icon(threadState.icon), title: Text(tr.upVoted.title), subtitle: Text(tr.upVoted.detail)), - ); - case ThreadStateModel.pictureAttached: - contents.add( - ListTile( - leading: Icon(threadState.icon), - title: Text(tr.pictureAttached.title), - subtitle: Text(tr.pictureAttached.detail), - ), - ); - case ThreadStateModel.digested: - contents.add( - ListTile( - leading: Icon(threadState.icon), - title: Text(tr.digested.title), - subtitle: Text(tr.digested.detail), - ), - ); - case ThreadStateModel.pinnedGlobally: - contents.add( - ListTile( - leading: Icon(threadState.icon), - title: Text(tr.pinnedGlobally.title), - subtitle: Text(tr.pinnedGlobally.detail), - ), - ); - case ThreadStateModel.pinnedInType: - contents.add( - ListTile( - leading: Icon(threadState.icon), - title: Text(tr.pinnedInType.title), - subtitle: Text(tr.pinnedInType.detail), - ), - ); - case ThreadStateModel.pinnedInSubreddit: - contents.add( - ListTile( - leading: Icon(threadState.icon), - title: Text(tr.pinnedInSubreddit.title), - subtitle: Text(tr.pinnedInSubreddit.detail), - ), - ); - case ThreadStateModel.poll: - contents.add( - ListTile(leading: Icon(threadState.icon), title: Text(tr.vote.title), subtitle: Text(tr.vote.detail)), - ); - case ThreadStateModel.rewarded: - contents.add( - ListTile( - leading: Icon(threadState.icon), - title: Text(tr.rewarded.title), - subtitle: Text(tr.rewarded.detail), - ), - ); - case ThreadStateModel.draft: - contents.add( - ListTile(leading: Icon(threadState.icon), title: Text(tr.draft.title), subtitle: Text(tr.draft.detail)), - ); - } - } + final items = <(IconData, String, String)>[ + for (final threadState in ThreadStateModel.values) + switch (threadState) { + ThreadStateModel.closed => (threadState.icon, tr.closed.title, tr.closed.detail), + ThreadStateModel.upVoted => (threadState.icon, tr.upVoted.title, tr.upVoted.detail), + ThreadStateModel.pictureAttached => (threadState.icon, tr.pictureAttached.title, tr.pictureAttached.detail), + ThreadStateModel.digested => (threadState.icon, tr.digested.title, tr.digested.detail), + ThreadStateModel.pinnedGlobally => (threadState.icon, tr.pinnedGlobally.title, tr.pinnedGlobally.detail), + ThreadStateModel.pinnedInType => (threadState.icon, tr.pinnedInType.title, tr.pinnedInType.detail), + ThreadStateModel.pinnedInSubreddit => ( + threadState.icon, + tr.pinnedInSubreddit.title, + tr.pinnedInSubreddit.detail, + ), + ThreadStateModel.poll => (threadState.icon, tr.vote.title, tr.vote.detail), + ThreadStateModel.rewarded => (threadState.icon, tr.rewarded.title, tr.rewarded.detail), + ThreadStateModel.draft => (threadState.icon, tr.draft.title, tr.draft.detail), + }, + ]; await showDialog( context: context, - builder: (_) => RootPage( - DialogPaths.threadCardHelp, - CustomAlertDialog.sync( - title: Text(tr.help), - content: Column(children: contents), - ), - ), + builder: (context) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return RootPage( + DialogPaths.threadCardHelp, + CustomAlertDialog.sync( + title: AppDialogTitle(icon: Icons.help_outline, title: tr.help), + // One row per mark: the icon as it appears on the card, its name and what it means. + content: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (icon, title, detail) in items) + Padding( + padding: edgeInsetsT4B4, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppIconTile(icon, size: 32), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text(detail, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, ); } - Widget _buildExampleRow(BuildContext context) { + Widget _buildExample(BuildContext context) { final tr = context.t.settingsPage.appearanceSection.threadCard.example; final someTime = DateTime.fromMillisecondsSinceEpoch(int.parse(tr.time)); - return Padding( - padding: edgeInsetsL12T4R12, - child: Row( + return NormalThreadCard( + NormalThread( + title: tr.title, + url: '', + // Not used + threadID: '114514', + author: User( + name: tr.author, + url: '', // Not used + ), + publishDate: someTime, + latestReplyAuthor: User( + name: tr.lastReplyAuthor, + url: '', // Not used + ), + latestReplyTime: DateTime.now().add(const Duration(minutes: -1)), + iconUrl: '', + // Not used + threadType: ThreadType( + name: tr.threadType, + url: '', // Not used + ), + viewCount: int.parse(tr.view), + replyCount: int.parse(tr.reply), + price: null, + privilege: 0, + css: null, + stateSet: {ThreadStateModel.pinnedGlobally, ThreadStateModel.digested}, + isRecentThread: true, + ), + disableTap: true, + ); + } + + /// The example card in a tinted block with its header, so it reads as a preview and not as a real thread. + Widget _buildPreview(BuildContext context) { + final tr = context.t.settingsPage.appearanceSection.threadCard; + return AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T4R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: NormalThreadCard( - NormalThread( - title: tr.title, - url: '', - // Not used - threadID: '114514', - author: User( - name: tr.author, - url: '', // Not used - ), - publishDate: someTime, - latestReplyAuthor: User( - name: tr.lastReplyAuthor, - url: '', // Not used - ), - latestReplyTime: DateTime.now().add(const Duration(minutes: -1)), - iconUrl: '', - // Not used - threadType: ThreadType( - name: tr.threadType, - url: '', // Not used - ), - viewCount: int.parse(tr.view), - replyCount: int.parse(tr.reply), - price: null, - privilege: 0, - css: null, - stateSet: {ThreadStateModel.pinnedGlobally, ThreadStateModel.digested}, - isRecentThread: true, - ), - disableTap: true, - ), - ), + AppSectionHeader(tr.preview, icon: Icons.preview_outlined), + _buildExample(context), ], ), ); } + Widget _buildOptions(BuildContext context, SettingsMap settings) { + final tr = context.t.settingsPage.appearanceSection.threadCard; + return AppTileGroup( + title: tr.title, + icon: Icons.tune_outlined, + children: [ + SectionSwitchListTile( + secondary: const Icon(Icons.format_align_center_outlined), + title: Text(tr.infoRowAlignCenter), + value: settings.threadCardInfoRowAlignCenter, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.threadCardInfoRowAlignCenter, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.person_pin_outlined), + title: Text(tr.showLastReplyAuthor), + value: settings.threadCardShowLastReplyAuthor, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.threadCardShowLastReplyAuthor, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.new_releases_outlined), + title: Text(tr.highlightRecentThread), + value: settings.threadCardHighlightRecentThread, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.threadCardHighlightRecentThread, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.badge_outlined), + title: Text(tr.highlightAuthorName), + value: settings.threadCardHighlightAuthorName, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.threadCardHighlightAuthorName, v)), + ), + SectionSwitchListTile( + secondary: const Icon(Icons.highlight_outlined), + title: Text(tr.highlightInfoRow), + value: settings.threadCardHighlightInfoRow, + onChanged: (v) => + context.read().add(SettingsValueChanged(SettingsKeys.threadCardHighlightInfoRow, v)), + ), + ], + ); + } + @override Widget build(BuildContext context) { final tr = context.t.settingsPage.appearanceSection.threadCard; @@ -171,54 +202,35 @@ class _SettingsThreadCardAppearancePageState extends State context.read().add( - SettingsValueChanged(SettingsKeys.threadCardInfoRowAlignCenter, v), - ), - ), - SectionSwitchListTile( - title: Text(tr.showLastReplyAuthor), - value: settings.threadCardShowLastReplyAuthor, - onChanged: (v) => context.read().add( - SettingsValueChanged(SettingsKeys.threadCardShowLastReplyAuthor, v), - ), - ), - SectionSwitchListTile( - title: Text(tr.highlightRecentThread), - value: settings.threadCardHighlightRecentThread, - onChanged: (v) => context.read().add( - SettingsValueChanged(SettingsKeys.threadCardHighlightRecentThread, v), - ), - ), - SectionSwitchListTile( - title: Text(tr.highlightAuthorName), - value: settings.threadCardHighlightAuthorName, - onChanged: (v) => context.read().add( - SettingsValueChanged(SettingsKeys.threadCardHighlightAuthorName, v), - ), - ), - SectionSwitchListTile( - title: Text(tr.highlightInfoRow), - value: settings.threadCardHighlightInfoRow, - onChanged: (v) => context.read().add( - SettingsValueChanged(SettingsKeys.threadCardHighlightInfoRow, v), - ), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= appTwoColumnWidth; + final padding = appCenteredPadding( + constraints.maxWidth, + maxWidth: wide ? appListMaxWidth : appFormMaxWidth, + ).copyWith(top: 12, bottom: 24 + MediaQuery.paddingOf(context).bottom); + // Wide windows: preview and switches side by side, the preview stays in view while toggling. + // Phones: the preview first, the switches under it, one scroll view (landscape and large text fit). + return ListView( + padding: padding, + children: [ + if (wide) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildPreview(context)), + const SizedBox(width: appSurfaceGap), + Expanded(child: _buildOptions(context, settings)), + ], + ) + else ...[ + _buildPreview(context), + const SizedBox(height: appSurfaceGap), + _buildOptions(context, settings), ], - ), - ), - Padding(padding: context.safePadding()), - ], + ], + ); + }, ), ), ); diff --git a/lib/features/settings/widgets/app_license_page.dart b/lib/features/settings/widgets/app_license_page.dart index c8e36bdef..0a01842e1 100644 --- a/lib/features/settings/widgets/app_license_page.dart +++ b/lib/features/settings/widgets/app_license_page.dart @@ -4,9 +4,13 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page to show app license and license of dependencies. +/// +/// The list of packages and the license texts are the Flutter [LicensePage] (with its own master/detail layout on +/// wide windows); this page only frames its loading and failure states the same way as the other pages. class AppLicensePage extends StatelessWidget { /// Constructor. const AppLicensePage({super.key}); @@ -18,8 +22,12 @@ class AppLicensePage extends StatelessWidget { builder: (context, snapshot) { if (snapshot.hasError) { return Scaffold( - appBar: AppBar(), - body: Center(child: Text(snapshot.error.toString())), + appBar: AppBar(title: Text(context.t.aboutPage.license)), + body: AppStateView( + icon: Icons.error_outline, + error: true, + message: '${context.t.general.failedToLoad}: ${snapshot.error}', + ), ); } if (snapshot.hasData) { @@ -28,12 +36,15 @@ class AppLicensePage extends StatelessWidget { applicationVersion: appFullVersion, applicationIcon: Column( mainAxisSize: MainAxisSize.min, - children: [sizedBoxW12H12, SvgPicture.asset(assetsLogoSvgPath, width: 192, height: 192), sizedBoxW12H12], + children: [sizedBoxW12H12, SvgPicture.asset(assetsLogoSvgPath, width: 120, height: 120), sizedBoxW12H12], ), applicationLegalese: snapshot.data, ); } - return const CircularIndicator(); + return Scaffold( + appBar: AppBar(title: Text(context.t.aboutPage.license)), + body: const CenteredCircularIndicator(), + ); }, ); } diff --git a/lib/features/settings/widgets/auto_clear_image_cache_duration_dialog.dart b/lib/features/settings/widgets/auto_clear_image_cache_duration_dialog.dart index e2e901697..6ac699247 100644 --- a/lib/features/settings/widgets/auto_clear_image_cache_duration_dialog.dart +++ b/lib/features/settings/widgets/auto_clear_image_cache_duration_dialog.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/features/settings/widgets/auto_sync_notice_dialog.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// Dialog for user select a duration for image cache considered outdated till last used time. @@ -46,57 +47,40 @@ class _AutoClearImageCacheDurationDialogState extends State= 0 ? defaultIndex : allTimes.indexOf(3600 * 24 * 7)).toDouble(); } + String _timeText(BuildContext context, int time) => switch (time) { + < 0 => context.t.general.never, + >= 0 && < 3600 => context.t.general.minutes(value: time ~/ 60), + >= 3600 && < 3600 * 24 => context.t.general.hours(value: time ~/ 3600), + _ => context.t.general.days(value: time ~/ (3600 * 24)), + }; + @override Widget build(BuildContext context) { final tr = context.t.settingsPage.storageSection.scheduledCleaning; final time = allTimes[_choiceIndex.toInt()]; - final currentTimeText = switch (time) { - < 0 => context.t.general.never, - >= 0 && < 3600 => context.t.general.minutes(value: time ~/ 60), - >= 3600 && < 3600 * 24 => context.t.general.hours(value: time ~/ 3600), - _ => context.t.general.days(value: time ~/ (3600 * 24)), - }; - return CustomAlertDialog.sync( - title: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(tr.duration.title), - sizedBoxW12H12, - Text( - tr.duration.detail, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.secondary), - ), - ], - ), - content: Column( - children: [ - Text( - currentTimeText, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary), - ), - sizedBoxW12H12, - Slider( - autofocus: true, - // Since flutter 3.29 - // ignore: deprecated_member_use - year2023: false, - max: allTimes.length.toDouble() - 1, - value: _choiceIndex, - divisions: allTimes.length - 1, - onChanged: (v) => setState(() => _choiceIndex = v), - ), - ], + title: AppDialogTitle(icon: Icons.auto_delete_outlined, title: tr.duration.title), + content: SettingsDurationSliderBody( + description: tr.duration.detail, + valueText: _timeText(context, time), + firstText: _timeText(context, allTimes.first), + lastText: _timeText(context, allTimes.last), + index: _choiceIndex, + count: allTimes.length, + onChanged: (v) => setState(() => _choiceIndex = v), ), actions: [ - TextButton(child: Text(context.t.general.ok), onPressed: () => context.pop(allTimes[_choiceIndex.toInt()])), + FilledButton(child: Text(context.t.general.ok), onPressed: () => context.pop(allTimes[_choiceIndex.toInt()])), ], ); } diff --git a/lib/features/settings/widgets/auto_sync_notice_dialog.dart b/lib/features/settings/widgets/auto_sync_notice_dialog.dart index 7c5937945..5c68ae453 100644 --- a/lib/features/settings/widgets/auto_sync_notice_dialog.dart +++ b/lib/features/settings/widgets/auto_sync_notice_dialog.dart @@ -2,8 +2,99 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; +/// Body shared by the duration dialogs of the settings: optional description, the chosen value in a block, a slider +/// between the first and the last choice. +class SettingsDurationSliderBody extends StatelessWidget { + /// Constructor. + const SettingsDurationSliderBody({ + required this.valueText, + required this.firstText, + required this.lastText, + required this.index, + required this.count, + required this.onChanged, + this.description, + super.key, + }); + + /// Optional explanation above the value. + final String? description; + + /// Text of the chosen value. + final String valueText; + + /// Text of the first choice, under the start of the slider. + final String firstText; + + /// Text of the last choice, under the end of the slider. + final String lastText; + + /// Index of the chosen value. + final double index; + + /// Number of choices. + final int count; + + /// Called with the new index. + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final edgeStyle = textTheme.labelSmall?.copyWith(color: colorScheme.outline); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (description != null) ...[ + Text(description!, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + sizedBoxW12H12, + ], + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.schedule_outlined, color: colorScheme.primary), + sizedBoxW8H8, + Flexible( + child: Text( + valueText, + style: textTheme.titleMedium?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold), + ), + ), + ], + ), + ), + sizedBoxW12H12, + Slider( + autofocus: true, + // Since flutter 3.29 + // ignore: deprecated_member_use + year2023: false, + max: count.toDouble() - 1, + value: index, + divisions: count - 1, + onChanged: onChanged, + ), + Row( + children: [ + Expanded(child: Text(firstText, style: edgeStyle)), + Expanded( + child: Text(lastText, textAlign: TextAlign.end, style: edgeStyle), + ), + ], + ), + ], + ); + } +} + /// Dialog for user selecting a duration on auto sync notice feature. class AutoSyncNoticeDialog extends StatefulWidget { /// Constructor. @@ -58,48 +149,35 @@ class _AutoSyncNoticeDialogState extends State { super.initState(); _choiceIndex = allTimes.contains(widget.currentSeconds) ? allTimes.indexOf(widget.currentSeconds).toDouble() - : 600.0; + // Index of the 10 minutes default. + : allTimes.indexOf(600).toDouble(); } + String _timeText(BuildContext context, int time) => switch (time) { + < 0 => context.t.general.never, + >= 0 && < 3600 => context.t.general.minutes(value: time ~/ 60), + _ => context.t.general.hours(value: time ~/ 3600), + }; + @override Widget build(BuildContext context) { final tr = context.t.settingsPage.behaviorSection.autoSyncNotice; final time = allTimes[_choiceIndex.toInt()]; - final currentTimeText = switch (time) { - < 0 => context.t.general.never, - >= 0 && < 3600 => context.t.general.minutes(value: time ~/ 60), - _ => context.t.general.hours(value: time ~/ 3600), - }; - return CustomAlertDialog.sync( - title: Text(tr.title), - content: Column( - children: [ - Text( - currentTimeText, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary), - ), - sizedBoxW12H12, - Slider( - autofocus: true, - // Since flutter 3.29 - // ignore: deprecated_member_use - year2023: false, - max: allTimes.length.toDouble() - 1, - value: _choiceIndex, - divisions: allTimes.length - 1, - onChanged: (v) { - setState(() { - _choiceIndex = v; - }); - }, - ), - ], + title: AppDialogTitle(icon: Icons.sync_outlined, title: tr.title), + content: SettingsDurationSliderBody( + description: tr.detail, + valueText: _timeText(context, time), + firstText: _timeText(context, allTimes.first), + lastText: _timeText(context, allTimes.last), + index: _choiceIndex, + count: allTimes.length, + onChanged: (v) => setState(() => _choiceIndex = v), ), actions: [ - TextButton(child: Text(context.t.general.ok), onPressed: () => context.pop(allTimes[_choiceIndex.toInt()])), + FilledButton(child: Text(context.t.general.ok), onPressed: () => context.pop(allTimes[_choiceIndex.toInt()])), ], ); } diff --git a/lib/features/settings/widgets/backup_secrets_dialogs.dart b/lib/features/settings/widgets/backup_secrets_dialogs.dart index 571b33f30..3b62dd147 100644 --- a/lib/features/settings/widgets/backup_secrets_dialogs.dart +++ b/lib/features/settings/widgets/backup_secrets_dialogs.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Shortest password accepted for a backup. const backupPasswordMinLength = 8; @@ -74,35 +75,48 @@ class _ExportBackupDialogState extends State<_ExportBackupDialog> { canPop: false, child: AlertDialog( scrollable: true, - title: Text(tr.title), + title: AppDialogTitle(icon: Icons.download_outlined, title: tr.title), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: Text(tr.includeAccounts), - subtitle: Text(tr.includeAccountsDetail), - value: includeAccounts, - onChanged: (v) => setState(() => includeAccounts = v), + // What the backup holds by default, as on the settings row. + Text( + context.t.settingsPage.advancedSection.exportDataDetail, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + sizedBoxW12H12, + AppInsetBlock( + outlined: true, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + title: Text(tr.includeAccounts), + subtitle: Text(tr.includeAccountsDetail), + value: includeAccounts, + onChanged: (v) => setState(() => includeAccounts = v), + ), ), if (includeAccounts) ...[ - sizedBoxW4H4, + sizedBoxW12H12, TextFormField( controller: password, obscureText: obscure, autofocus: true, - decoration: InputDecoration(labelText: tr.password, suffixIcon: toggle), + decoration: appFieldDecoration(label: tr.password, icon: Icons.lock_outline, suffix: toggle), validator: (v) => (v == null || v.length < backupPasswordMinLength) ? tr.passwordTooShort(min: backupPasswordMinLength) : null, ), - sizedBoxW4H4, + sizedBoxW12H12, TextFormField( controller: confirm, obscureText: obscure, - decoration: InputDecoration(labelText: tr.confirmPassword), + decoration: appFieldDecoration(label: tr.confirmPassword, icon: Icons.lock_reset_outlined), validator: (v) => v != password.text ? tr.passwordMismatch : null, onFieldSubmitted: (_) => _submit(), ), @@ -151,25 +165,25 @@ class _UnlockBackupDialogState extends State<_UnlockBackupDialog> { canPop: false, child: AlertDialog( scrollable: true, - title: Text(tr.title), + title: AppDialogTitle(icon: Icons.lock_open_outlined, title: tr.title), content: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(tr.tip), - sizedBoxW4H4, + AppNoticeBanner(message: tr.tip, icon: Icons.key_outlined), + sizedBoxW12H12, TextField( controller: password, obscureText: obscure, autofocus: true, - decoration: InputDecoration( - labelText: tr.password, - errorText: widget.wrongPassword ? tr.wrongPassword : null, - suffixIcon: IconButton( + decoration: appFieldDecoration( + label: tr.password, + icon: Icons.lock_outline, + suffix: IconButton( icon: Icon(obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined), onPressed: () => setState(() => obscure = !obscure), ), - ), + ).copyWith(errorText: widget.wrongPassword ? tr.wrongPassword : null), onSubmitted: (_) => _submit(), ), ], diff --git a/lib/features/settings/widgets/check_in_dialog.dart b/lib/features/settings/widgets/check_in_dialog.dart index 854d8aa37..d32600b5e 100644 --- a/lib/features/settings/widgets/check_in_dialog.dart +++ b/lib/features/settings/widgets/check_in_dialog.dart @@ -4,6 +4,7 @@ import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/features/checkin/models/models.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; @@ -18,7 +19,7 @@ class CheckinFeelingDialog extends StatelessWidget { @override Widget build(BuildContext context) { return CustomAlertDialog.sync( - title: Text(context.t.settingsPage.checkinSection.feeling), + title: AppDialogTitle(icon: Icons.emoji_emotions_outlined, title: context.t.settingsPage.checkinSection.feeling), content: Column( children: CheckinFeeling.values .map( @@ -70,38 +71,39 @@ class _CheckinMessageDialogState extends State { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; return CustomAlertDialog.sync( - title: Text(context.t.settingsPage.checkinSection.anythingToSay), - content: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Expanded( - child: Form( - key: formKey, - child: TextFormField( - autofocus: true, - validator: (_) { - if (textRestLength >= 47) { - return context.t.checkinForm.shouldMoreThan3; - } - if (textRestLength < 0) { - return context.t.checkinForm.shouldNoMoreThan50; - } - return null; - }, - onChanged: (value) { - setState(() { - textRestLength = _maxTextLength - value.parseUtf8Length; - }); - }, - controller: textController, - inputFormatters: [LengthLimitingTextInputFormatter(_maxTextLength)], - ), + title: AppDialogTitle(icon: Icons.textsms_outlined, title: context.t.settingsPage.checkinSection.anythingToSay), + content: Padding( + // Room for the floating label of the filled field. + padding: edgeInsetsT4, + child: Form( + key: formKey, + child: TextFormField( + autofocus: true, + // The remaining length sits in the field, it no longer takes a column beside it on narrow phones. + decoration: appFieldDecoration(icon: Icons.edit_note_outlined).copyWith( + counterText: '$textRestLength', + counterStyle: TextStyle(color: textRestLength < 0 ? colorScheme.error : colorScheme.onSurfaceVariant), ), + validator: (_) { + if (textRestLength >= 47) { + return context.t.checkinForm.shouldMoreThan3; + } + if (textRestLength < 0) { + return context.t.checkinForm.shouldNoMoreThan50; + } + return null; + }, + onChanged: (value) { + setState(() { + textRestLength = _maxTextLength - value.parseUtf8Length; + }); + }, + controller: textController, + inputFormatters: [LengthLimitingTextInputFormatter(_maxTextLength)], ), - sizedBoxW24H24, - Text('$textRestLength'), - ], + ), ), actions: [ TextButton( @@ -110,7 +112,7 @@ class _CheckinMessageDialogState extends State { Navigator.of(context).pop(); }, ), - TextButton( + FilledButton( child: Text(context.t.general.ok), onPressed: () async { // Validate diff --git a/lib/features/settings/widgets/clear_cache_bottom_sheet.dart b/lib/features/settings/widgets/clear_cache_bottom_sheet.dart index 87c0f8668..6593abdb0 100644 --- a/lib/features/settings/widgets/clear_cache_bottom_sheet.dart +++ b/lib/features/settings/widgets/clear_cache_bottom_sheet.dart @@ -12,6 +12,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Show a bottom sheet provides clear cache functionality with clear cache @@ -54,39 +55,51 @@ class _ClearCacheBottomSheetState extends State<_ClearCacheBottomSheet> { builder: (context, state) { final body = switch (state.status) { SettingsCacheStatus.loaded || SettingsCacheStatus.cleared => SingleChildScrollView( + padding: edgeInsetsL16R16, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - CheckboxListTile( - secondary: const Icon(Icons.image_outlined), - title: Text(tr.images), - subtitle: Text(state.storageInfo!.imageSize.withSizeHint()), - value: state.clearInfo.clearImage, - onChanged: (v) => context.read().add( - SettingsCacheUpdateClearInfoRequested(state.clearInfo.copyWith(clearImage: v)), - ), - ), - CheckboxListTile( - secondary: const Icon(Icons.emoji_emotions_outlined), - title: Text(tr.emoji), - subtitle: Text(state.storageInfo!.emojiSize.withSizeHint()), - value: state.clearInfo.clearEmoji, - onChanged: (v) => context.read().add( - SettingsCacheUpdateClearInfoRequested(state.clearInfo.copyWith(clearEmoji: v)), - ), - ), - CheckboxListTile( - secondary: const Icon(Symbols.text_ad), - title: Text(tr.log), - subtitle: Text(state.storageInfo!.logSize.withSizeHint()), - value: state.clearInfo.clearLog, - onChanged: (v) => context.read().add( - SettingsCacheUpdateClearInfoRequested(state.clearInfo.copyWith(clearLog: v)), + AppNoticeBanner(message: tr.downloadAgainInfo), + sizedBoxW12H12, + AppInsetBlock( + outlined: true, + padding: edgeInsetsT4B4, + child: Column( + children: [ + CheckboxListTile( + secondary: const Icon(Icons.image_outlined), + title: Text(tr.images), + subtitle: Text(state.storageInfo!.imageSize.withSizeHint()), + value: state.clearInfo.clearImage, + onChanged: (v) => context.read().add( + SettingsCacheUpdateClearInfoRequested(state.clearInfo.copyWith(clearImage: v)), + ), + ), + CheckboxListTile( + secondary: const Icon(Icons.emoji_emotions_outlined), + title: Text(tr.emoji), + subtitle: Text(state.storageInfo!.emojiSize.withSizeHint()), + value: state.clearInfo.clearEmoji, + onChanged: (v) => context.read().add( + SettingsCacheUpdateClearInfoRequested(state.clearInfo.copyWith(clearEmoji: v)), + ), + ), + CheckboxListTile( + secondary: const Icon(Symbols.text_ad), + title: Text(tr.log), + subtitle: Text(state.storageInfo!.logSize.withSizeHint()), + value: state.clearInfo.clearLog, + onChanged: (v) => context.read().add( + SettingsCacheUpdateClearInfoRequested(state.clearInfo.copyWith(clearLog: v)), + ), + ), + ], ), ), ], ), ), - _ => const CenteredCircularIndicator(), + _ => const Padding(padding: edgeInsetsL24T24R24B24, child: CenteredCircularIndicator()), }; return Column( @@ -97,8 +110,9 @@ class _ClearCacheBottomSheetState extends State<_ClearCacheBottomSheet> { children: [ Expanded( child: Padding( - padding: edgeInsetsL12T12R12B12.add(context.safePadding()), - child: FilledButton( + padding: edgeInsetsL16T12R16B12.add(context.safePadding()), + child: FilledButton.icon( + icon: const Icon(Icons.cleaning_services_outlined), onPressed: state.status == SettingsCacheStatus.loaded ? () { if (state.clearInfo.hasSelected) { @@ -110,7 +124,7 @@ class _ClearCacheBottomSheetState extends State<_ClearCacheBottomSheet> { } } : null, - child: Text(context.t.general.ok), + label: Text(context.t.general.ok), ), ), ), diff --git a/lib/features/settings/widgets/color_picker_dialog.dart b/lib/features/settings/widgets/color_picker_dialog.dart index e8a8cfcf0..686ccfdc3 100644 --- a/lib/features/settings/widgets/color_picker_dialog.dart +++ b/lib/features/settings/widgets/color_picker_dialog.dart @@ -21,7 +21,8 @@ final class ColorPickerDialog extends StatelessWidget { const items = Colors.primaries; return GridView.builder( - padding: context.safePadding(), + // Same side margin as the other sheets, above the system insets. + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16).add(context.safePadding()), shrinkWrap: true, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: _colorBoxSize, diff --git a/lib/features/settings/widgets/font_family_dialog.dart b/lib/features/settings/widgets/font_family_dialog.dart index c3cb6907d..84bda6a16 100644 --- a/lib/features/settings/widgets/font_family_dialog.dart +++ b/lib/features/settings/widgets/font_family_dialog.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// Dialog for choosing font family. @@ -33,12 +35,43 @@ class _FontFamilyDialogState extends State { @override Widget build(BuildContext context) { final tr = context.t.settingsPage.appearanceSection.fontFamily; + final textTheme = Theme.of(context).textTheme; + final font = _fontController.text.trim(); return CustomAlertDialog.sync( - title: Text(tr.dialogTitle), - content: TextField(controller: _fontController, autofocus: true), + title: AppDialogTitle(icon: Icons.font_download_outlined, title: tr.dialogTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + // Room for the floating label of the filled field. + padding: edgeInsetsT4, + child: TextField( + controller: _fontController, + autofocus: true, + decoration: appFieldDecoration(label: tr.title, icon: Icons.text_fields_outlined), + onChanged: (_) => setState(() {}), + ), + ), + sizedBoxW12H12, + // Preview of the typed family; an unknown family falls back to the default font like the app does. + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Text( + '${context.t.appName}\nAa Bb Cc 0123', + style: textTheme.titleMedium?.copyWith(fontFamily: font.isEmpty ? null : font), + ), + ), + ], + ), actions: [ - TextButton(child: Text(context.t.general.reset), onPressed: () => context.pop('')), - TextButton(child: Text(context.t.general.ok), onPressed: () => context.pop(_fontController.text)), + TextButton.icon( + icon: const Icon(Icons.restart_alt_outlined), + label: Text(context.t.general.reset), + onPressed: () => context.pop(''), + ), + FilledButton(child: Text(context.t.general.ok), onPressed: () => context.pop(_fontController.text)), ], ); } diff --git a/lib/features/settings/widgets/font_scale_dialog.dart b/lib/features/settings/widgets/font_scale_dialog.dart index dc2585c11..910b13998 100644 --- a/lib/features/settings/widgets/font_scale_dialog.dart +++ b/lib/features/settings/widgets/font_scale_dialog.dart @@ -1,8 +1,16 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; +/// Smallest text scale offered. +const _minScale = 0.7; + +/// Largest text scale offered. +const _maxScale = 1.5; + /// Dialog to show available text scale options as a chooser. class TextScaleDialog extends StatefulWidget { /// Constructor. @@ -27,27 +35,54 @@ class _TextScaleDialogState extends State { @override Widget build(BuildContext context) { final tr = context.t.settingsPage.appearanceSection.textScaleFactor; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; return CustomAlertDialog.sync( - title: Text(tr.dialogTitle), + title: AppDialogTitle(icon: Icons.text_increase_outlined, title: tr.dialogTitle), content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 12, children: [ - Text(_currentScale.toString()), - Slider( - autofocus: true, - // Since flutter 3.29 - // ignore: deprecated_member_use - year2023: false, - min: 0.7, - max: 1.5, - divisions: 8, - value: _currentScale, - onChanged: (v) => setState(() => _currentScale = double.parse(v.toStringAsFixed(2))), + Text( + '${_currentScale}x', + textAlign: TextAlign.center, + style: textTheme.headlineSmall?.copyWith(color: colorScheme.primary, fontWeight: FontWeight.bold), + ), + // Preview at the chosen factor, on top of the scale the system already applies. + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Text( + context.t.appName, + textScaler: TextScaler.linear(MediaQuery.textScalerOf(context).scale(1) * _currentScale), + style: textTheme.bodyMedium, + ), + ), + Row( + children: [ + Icon(Icons.text_decrease_outlined, size: 18, color: colorScheme.outline), + Expanded( + child: Slider( + autofocus: true, + // Since flutter 3.29 + // ignore: deprecated_member_use + year2023: false, + min: _minScale, + max: _maxScale, + divisions: 8, + value: _currentScale, + label: '${_currentScale}x', + onChanged: (v) => setState(() => _currentScale = double.parse(v.toStringAsFixed(2))), + ), + ), + Icon(Icons.text_increase_outlined, size: 18, color: colorScheme.outline), + ], ), ], ), - actions: [TextButton(onPressed: () => context.pop(_currentScale), child: Text(context.t.general.ok))], + actions: [FilledButton(onPressed: () => context.pop(_currentScale), child: Text(context.t.general.ok))], ); } } diff --git a/lib/features/settings/widgets/language_dialog.dart b/lib/features/settings/widgets/language_dialog.dart index 0d1b22a38..b511266f1 100644 --- a/lib/features/settings/widgets/language_dialog.dart +++ b/lib/features/settings/widgets/language_dialog.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; @@ -14,14 +15,19 @@ class LanguageDialog extends StatelessWidget { @override Widget build(BuildContext context) { return CustomAlertDialog.sync( - title: Text(t.settingsPage.appearanceSection.languages.selectLanguage), + title: AppDialogTitle( + icon: Icons.translate_outlined, + title: t.settingsPage.appearanceSection.languages.selectLanguage, + ), content: Column( children: [ SelectableListTile( + leading: currentLocale == '' ? null : const Icon(Icons.phone_android_outlined), title: Text(t.settingsPage.appearanceSection.languages.followSystem), selected: currentLocale == '', onTap: () async => Navigator.of(context).pop((null, true)), ), + const Divider(indent: 24, endIndent: 24), ...AppLocale.values.map( (e) => SelectableListTile( // TODO: Check if is caused by lazy loading. @@ -36,6 +42,7 @@ class LanguageDialog extends StatelessWidget { 'language tag $v', ), }), + subtitle: Text(e.languageTag), selected: currentLocale == e.languageTag, onTap: () async => Navigator.of(context).pop((e, false)), ), diff --git a/lib/features/settings/widgets/proxy_settings_dialog.dart b/lib/features/settings/widgets/proxy_settings_dialog.dart index 39b439c90..4e5fb6696 100644 --- a/lib/features/settings/widgets/proxy_settings_dialog.dart +++ b/lib/features/settings/widgets/proxy_settings_dialog.dart @@ -8,6 +8,7 @@ import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// Dialog for proxy settings. @@ -67,10 +68,13 @@ class _ProxySettingsDialogState extends State { ), options: Options(sendTimeout: const Duration(seconds: 3)), ); + // The dialog may have been closed while waiting. + if (!mounted) return; setState(() { connStatus = _TestConnStatus.connected; }); } on DioException catch (e, _) { + if (!mounted) return; setState(() { connStatus = _TestConnStatus.connected; }); @@ -105,17 +109,25 @@ class _ProxySettingsDialogState extends State { @override Widget build(BuildContext context) { final tr = context.t.settingsPage.advancedSection.proxySettings; + final colorScheme = Theme.of(context).colorScheme; + final (IconData statusIcon, Color statusColor) = switch (connStatus) { + _TestConnStatus.waiting => (Icons.help_outline, colorScheme.outline), + _TestConnStatus.testing => (Icons.hourglass_top_outlined, colorScheme.outline), + _TestConnStatus.connected => (Icons.check_circle_outline, colorScheme.primary), + _TestConnStatus.disconnected => (Icons.error_outline, colorScheme.error), + }; return CustomAlertDialog.sync( - title: Text(tr.title), + title: AppDialogTitle(icon: Icons.network_locked_outlined, title: tr.title), content: Form( key: formKey, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + sizedBoxW4H4, TextFormField( controller: hostController, autofocus: true, - decoration: InputDecoration(prefixIcon: Icon(MdiIcons.ipNetworkOutline), labelText: tr.host), + decoration: appFieldDecoration(label: tr.host).copyWith(prefixIcon: Icon(MdiIcons.ipNetworkOutline)), validator: (v) { if (v == null || v.trim().isEmpty || v.contains(':')) { return tr.invalidHostOrIp; @@ -123,10 +135,11 @@ class _ProxySettingsDialogState extends State { return null; }, ), - sizedBoxW16H16, + sizedBoxW12H12, TextFormField( controller: portController, - decoration: InputDecoration(prefixIcon: const Icon(Icons.network_ping_outlined), labelText: tr.port), + keyboardType: TextInputType.number, + decoration: appFieldDecoration(label: tr.port, icon: Icons.network_ping_outlined), validator: (v) { if (v == null || v.trim().isEmpty) { return tr.invalidPort; @@ -138,33 +151,48 @@ class _ProxySettingsDialogState extends State { return null; }, ), - sizedBoxW16H16, - TextButton.icon( - label: Text(switch (connStatus) { - _TestConnStatus.waiting => tr.testConnection.waiting, - _TestConnStatus.testing => tr.testConnection.testing, - _TestConnStatus.connected => tr.testConnection.connected, - _TestConnStatus.disconnected => tr.testConnection.disconnected, - }), - icon: const Icon(Icons.refresh_outlined), - onPressed: connStatus == _TestConnStatus.testing ? null : () async => testConnection(), + sizedBoxW12H12, + // Connection test: the status and its button in one block. + AppInsetBlock( + outlined: true, + child: Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (connStatus == _TestConnStatus.testing) + const SizedBox.square(dimension: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + Icon(statusIcon, size: 20, color: statusColor), + OutlinedButton.icon( + label: Text(switch (connStatus) { + _TestConnStatus.waiting => tr.testConnection.waiting, + _TestConnStatus.testing => tr.testConnection.testing, + _TestConnStatus.connected => tr.testConnection.connected, + _TestConnStatus.disconnected => tr.testConnection.disconnected, + }), + icon: const Icon(Icons.refresh_outlined), + onPressed: connStatus == _TestConnStatus.testing ? null : () async => testConnection(), + ), + ], + ), ), ], ), ), actions: [ - TextButton( + TextButton.icon( + icon: const Icon(Icons.restart_alt_outlined), onPressed: () { // Clear proxy settings. context.read().add(const SettingsValueChanged(SettingsKeys.netClientProxy, '')); showSnackBar(context: context, message: context.t.general.affectAfterRestart); context.pop(); }, - child: Text(context.t.general.reset), + label: Text(context.t.general.reset), ), - sizedBoxW24H24, TextButton(onPressed: () => context.pop(), child: Text(context.t.general.cancel)), - TextButton( + FilledButton( onPressed: () { if (!formKey.currentState!.validate()) { return; diff --git a/lib/features/settings/widgets/select_thread_floor_interaction_mode_dialog.dart b/lib/features/settings/widgets/select_thread_floor_interaction_mode_dialog.dart index 312acc8a7..516455da1 100644 --- a/lib/features/settings/widgets/select_thread_floor_interaction_mode_dialog.dart +++ b/lib/features/settings/widgets/select_thread_floor_interaction_mode_dialog.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/thread_floor_interaction_mode.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/selectable_list_tile.dart'; @@ -26,15 +28,27 @@ class _SelectThreadFloorInteractionModeDialog extends StatelessWidget { Widget build(BuildContext context) { final tr = context.t.settingsPage.behaviorSection.threadFloorInteractionMode; return CustomAlertDialog.sync( - title: Text(tr.title), + title: AppDialogTitle(icon: Icons.touch_app_outlined, title: tr.title), content: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: edgeInsetsL24R24.add(const EdgeInsets.only(bottom: 8)), + child: Text( + tr.detail, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), SelectableListTile( + leading: initialMode == ThreadFloorInteractionMode.adaptiveTapMenu ? null : const Icon(Icons.menu_open), title: Text(tr.adaptiveTapOpenContextMenu), selected: initialMode == ThreadFloorInteractionMode.adaptiveTapMenu, onTap: () => context.pop(ThreadFloorInteractionMode.adaptiveTapMenu), ), SelectableListTile( + leading: initialMode == ThreadFloorInteractionMode.tapToReply ? null : const Icon(Icons.reply_outlined), title: Text(tr.tapToReply), selected: initialMode == ThreadFloorInteractionMode.tapToReply, onTap: () => context.pop(ThreadFloorInteractionMode.tapToReply), diff --git a/lib/features/settings/widgets/support_development_dialog.dart b/lib/features/settings/widgets/support_development_dialog.dart index 917444999..d69e93264 100644 --- a/lib/features/settings/widgets/support_development_dialog.dart +++ b/lib/features/settings/widgets/support_development_dialog.dart @@ -3,11 +3,13 @@ import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/browser_launcher.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/platform.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; final _featureRequestUri = Uri.https('github.com', '/Carinoasd/tsdm_client/issues/new', { 'template': '02_feedback.yml', @@ -93,8 +95,12 @@ class _SupportDevelopmentDialogState extends State wit @override Widget build(BuildContext context) { final tr = context.t.aboutPage; + final colorScheme = Theme.of(context).colorScheme; + final mutedStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant); + // Same texts, order and actions as before; only grouped: the voluntary nature first, then the feature request + // block, then the donation code on a white ground so it stays scannable in the dark theme. return AlertDialog( - title: Text(tr.supportDevelopment), + title: AppDialogTitle(icon: Icons.volunteer_activism_outlined, title: tr.supportDevelopment), scrollable: true, content: SizedBox( width: 360, @@ -102,28 +108,59 @@ class _SupportDevelopmentDialogState extends State wit mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text(tr.donationDescription), + Text(tr.donationDescription, style: mutedStyle), const SizedBox(height: 16), - Text(tr.featureRequestTitle, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(tr.featureRequestDescription), - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: _openingRequest ? null : _openFeatureRequest, - icon: const Icon(Icons.lightbulb_outline), - label: Text(tr.featureRequestAction), + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T4R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + AppSectionHeader(tr.featureRequestTitle, icon: Icons.lightbulb_outline), + Text(tr.featureRequestDescription, style: mutedStyle), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: _openingRequest ? null : _openFeatureRequest, + // No spinner while the browser opens: the disabled button is the pending state. + icon: const Icon(Icons.open_in_new), + label: Text(tr.featureRequestAction), + ), + if (_requestOpenFailed) ...[ + const SizedBox(height: 8), + AppNoticeBanner(message: tr.featureRequestOpenFailed, tone: AppNoticeTone.error), + const SizedBox(height: 4), + SelectableText( + _featureRequestUri.toString(), + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.primary), + ), + ], + ], + ), ), - if (_requestOpenFailed) ...[ - const SizedBox(height: 8), - Text(tr.featureRequestOpenFailed, style: TextStyle(color: Theme.of(context).colorScheme.error)), - SelectableText(_featureRequestUri.toString()), - ], - const Padding(padding: EdgeInsets.symmetric(vertical: 8), child: Divider()), - Text(tr.donationTitle, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Image.asset(SupportDevelopmentDialog.imagePath, semanticLabel: tr.donationCodeLabel), const SizedBox(height: 16), - Text(tr.donationInstructions), + AppSectionHeader(tr.donationTitle, icon: Icons.qr_code_2_outlined), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 280), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(appInnerRadius), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.asset(SupportDevelopmentDialog.imagePath, semanticLabel: tr.donationCodeLabel), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Text(tr.donationInstructions, style: mutedStyle), ], ), ), diff --git a/lib/features/settings/widgets/thread_content_scale_dialog.dart b/lib/features/settings/widgets/thread_content_scale_dialog.dart new file mode 100644 index 000000000..4e053fb13 --- /dev/null +++ b/lib/features/settings/widgets/thread_content_scale_dialog.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; + +/// Lower bound of the extra thread content scale: no extra scale. +const threadContentScaleMin = 1.0; + +/// Upper bound of the extra thread content scale. +const threadContentScaleMax = 2.0; + +/// Dialog to pick the extra text scale applied to floors in thread page (GitHub #137). +class ThreadContentScaleDialog extends StatefulWidget { + /// Constructor. + const ThreadContentScaleDialog(this.initialScale, {super.key}); + + /// Thread content scale when enter this widget. + final double initialScale; + + @override + State createState() => _ThreadContentScaleDialogState(); +} + +class _ThreadContentScaleDialogState extends State { + late double _currentScale; + + @override + void initState() { + super.initState(); + _currentScale = widget.initialScale.clamp(threadContentScaleMin, threadContentScaleMax); + } + + @override + Widget build(BuildContext context) { + final tr = context.t.settingsPage.appearanceSection.threadContentScale; + + return CustomAlertDialog.sync( + title: Text(tr.dialogTitle), + content: Column( + spacing: 12, + children: [ + Text(_currentScale.toString()), + Slider( + autofocus: true, + // Since flutter 3.29 + // ignore: deprecated_member_use + year2023: false, + min: threadContentScaleMin, + max: threadContentScaleMax, + divisions: 10, + value: _currentScale, + onChanged: (v) => setState(() => _currentScale = double.parse(v.toStringAsFixed(1))), + ), + ], + ), + actions: [TextButton(onPressed: () => context.pop(_currentScale), child: Text(context.t.general.ok))], + ); + } +} diff --git a/lib/features/thread/v1/view/thread_page.dart b/lib/features/thread/v1/view/thread_page.dart index a9f94585e..d8227234f 100644 --- a/lib/features/thread/v1/view/thread_page.dart +++ b/lib/features/thread/v1/view/thread_page.dart @@ -42,6 +42,7 @@ import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/platform.dart'; import 'package:tsdm_client/utils/retry_button.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/error_card.dart'; import 'package:tsdm_client/widgets/card/post_card/post_card.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -144,6 +145,9 @@ bool _firstFloorNamesNoUser(uh.Document document) { return first != null && first.querySelector('td.pls a[href*="uid="], div.authi a[href*="uid="]') == null; } +/// Upper bound of the combined text scale (global text scale times thread content scale) applied to floors. +const threadContentMaxTextScale = 3.0; + class _ThreadPageState extends State with SingleTickerProviderStateMixin, LoggerMixin { /// Controller of thread tab. final _listScrollController = ScrollController(); @@ -410,94 +414,114 @@ class _ThreadPageState extends State with SingleTickerProviderStateM ); } + /// Height of the breadcrumb row under the app bar for the extra height of large text. + static double _breadcrumbsHeight(double extraHeight) => 26 + extraHeight; + + /// Forum path (links separated by chevrons) followed by small pills: thread type, thread id (copy dialog), + /// statistics, draft and the local replied mark. Scrolls horizontally and starts scrolled to the end, where the + /// thread's own information is. Widget _buildBreadcrumbsRow(ThreadState state, double extraHeight, {required bool replied}) { - final infoTextStyle = Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.outline); - - final infoTextHighlightStyle = Theme.of( - context, - ).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.primary); - - final breadFrags = state.breadcrumbs - .map( - (e) => [ - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () async { - final gid = e.link.tryGetQueryParameters()?['gid']; - if (gid != null) { - await context.pushNamed( - ScreenPaths.forumGroup, - pathParameters: {'gid': gid}, - queryParameters: {'title': e.description}, - ); - return; - } - await context.dispatchAsUrl(e.link.toString()); - }, - child: Text(e.description, style: infoTextHighlightStyle), - ), - ), - const Text(' > '), + final colorScheme = Theme.of(context).colorScheme; + final infoTextStyle = Theme.of(context).textTheme.labelLarge?.copyWith(color: colorScheme.outline); + final infoTextHighlightStyle = Theme.of(context).textTheme.labelLarge?.copyWith(color: colorScheme.primary); + + Widget pill(String label, {IconData? icon, Color? color, VoidCallback? onTap}) { + final foreground = color ?? colorScheme.onSurfaceVariant; + final content = Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[Icon(icon, size: 14, color: foreground), sizedBoxW4H4], + Text(label, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: foreground)), ], - ) - .flattenedToList; + ), + ); + return Padding( + padding: const EdgeInsets.only(left: 6), + child: Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + clipBehavior: Clip.antiAlias, + child: onTap == null + ? Center(child: content) + : InkWell( + onTap: onTap, + child: Center(child: content), + ), + ), + ); + } + + final breadFrags = [ + for (final (index, e) in state.breadcrumbs.indexed) ...[ + if (index > 0) Icon(Icons.chevron_right, size: 16, color: colorScheme.outline), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async { + final gid = e.link.tryGetQueryParameters()?['gid']; + if (gid != null) { + await context.pushNamed( + ScreenPaths.forumGroup, + pathParameters: {'gid': gid}, + queryParameters: {'title': e.description}, + ); + return; + } + await context.dispatchAsUrl(e.link.toString()); + }, + child: Center(child: Text(e.description, style: infoTextHighlightStyle)), + ), + ), + ], + if (state.breadcrumbs.isNotEmpty) sizedBoxW4H4, + ]; return Padding( padding: edgeInsetsL12R12.add(edgeInsetsB4), child: DefaultTextStyle.merge( style: infoTextStyle, child: SizedBox( - height: 20 + extraHeight, + height: _breadcrumbsHeight(extraHeight) - 4, child: ListView( scrollDirection: Axis.horizontal, reverse: true, children: [ ...breadFrags, if (state.threadType?.typeID != null && state.fid != null) - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () async => context.pushNamed( - ScreenPaths.forum, - pathParameters: {'fid': '${state.fid}'}, - queryParameters: { - 'threadTypeName': state.threadType?.name, - 'threadTypeID': '${state.threadType?.typeID}', - }, - ), - child: Text('[${state.threadType!.name}]', style: infoTextHighlightStyle), + pill( + state.threadType!.name, + icon: Icons.label_outline, + color: colorScheme.primary, + onTap: () async => context.pushNamed( + ScreenPaths.forum, + pathParameters: {'fid': '${state.fid}'}, + queryParameters: { + 'threadTypeName': state.threadType?.name, + 'threadTypeID': '${state.threadType?.typeID}', + }, ), ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () async { - final id = state.tid ?? widget.threadID; - final title = state.title ?? widget.title; - await showCopyThreadInfoDialog(context: context, tid: id, title: title); - }, - child: Text('[${context.t.threadPage.title} ${state.tid ?? ""}]', style: infoTextHighlightStyle), - ), + pill( + '${context.t.threadPage.title} ${state.tid ?? ""}', + icon: Icons.tag, + color: colorScheme.primary, + onTap: () async { + final id = state.tid ?? widget.threadID; + final title = state.title ?? widget.title; + await showCopyThreadInfoDialog(context: context, tid: id, title: title); + }, ), if (state.viewCount != null || state.replyCount != null) - Text('[${context.t.threadPage.statistics(view: state.viewCount ?? 0, reply: state.replyCount ?? 0)}]'), - if (state.isDraft) Text('[${context.t.threadPage.draft}]'), + pill( + context.t.threadPage.statistics(view: state.viewCount ?? 0, reply: state.replyCount ?? 0), + icon: Icons.bar_chart_outlined, + ), + if (state.isDraft) pill(context.t.threadPage.draft, icon: Icons.edit_note_outlined), // The current account replied in this thread (local mark, issue #21). if (replied) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.reply_outlined, size: 16, color: Theme.of(context).colorScheme.tertiary), - sizedBoxW4H4, - Text( - context.t.threadPage.repliedMark, - style: infoTextStyle?.copyWith(color: Theme.of(context).colorScheme.tertiary), - ), - ], - ), + pill(context.t.threadPage.repliedMark, icon: Icons.reply_outlined, color: colorScheme.tertiary), ].reversed.toList(), ), ), @@ -526,47 +550,59 @@ class _ThreadPageState extends State with SingleTickerProviderStateM WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToPidOnReload = null); } + // The posts keep the safe area; the soft close banner and the reply bar paint their background to the screen + // edges and pad the side insets themselves (feedback 110: a blank strip beside the bar in landscape). return Column( children: [ Expanded( - child: PostList( - threadID: state.tid ?? widget.threadID, - title: state.title ?? widget.title, - pageNumber: context.read().state.currentPage, - initialPostID: (_scrollToPidOnReload ?? widget.findPostID)?.parseToInt(), - scrollController: _listScrollController, - // Posts of locally blocked users become placeholders in place, the floors keep their positions. - widgetBuilder: (context, post) => BlockAwarePost( - post: post, - postList: state.postList, - builder: (context, post) => PostCard( - post, - replyCallback: replyPostCallback, - onEdited: () => _scrollToPidOnReload = post.postID, + // Floors get the extra thread content scale on top of the global text scale (GitHub #137); the app bar, + // the hints below and the reply bar keep the global scale. + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: _threadContentScaler(context)), + child: SafeArea( + bottom: false, + child: PostList( + threadID: state.tid ?? widget.threadID, + title: state.title ?? widget.title, + pageNumber: context.read().state.currentPage, + initialPostID: (_scrollToPidOnReload ?? widget.findPostID)?.parseToInt(), + scrollController: _listScrollController, + // Posts of locally blocked users become placeholders in place, the floors keep their positions. + widgetBuilder: (context, post) => BlockAwarePost( + post: post, + postList: state.postList, + builder: (context, post) => PostCard( + post, + replyCallback: replyPostCallback, + onEdited: () => _scrollToPidOnReload = post.postID, + ), + ), + useDivider: true, + postList: state.postList, + canLoadMore: state.canLoadMore, + latestModAct: state.latestModAct, ), ), - useDivider: true, - postList: state.postList, - canLoadMore: state.canLoadMore, - latestModAct: state.latestModAct, ), ), + // Replies are still accepted but the thread is about to close: a banner right above the reply box, in the + // reading column. if (state.threadSoftClosed && !state.threadClosed) ColoredBox( - color: Theme.of(context).colorScheme.secondaryContainer, - child: Padding( - padding: edgeInsetsL12T4R12B4, - child: Row( - children: [ - Icon(Icons.lock_outline, size: 16, color: Theme.of(context).colorScheme.onSecondaryContainer), - sizedBoxW4H4, - Expanded( - child: Text( - tr.softCloseHint, - style: TextStyle(color: Theme.of(context).colorScheme.onSecondaryContainer), - ), + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: SafeArea( + top: false, + bottom: false, + child: Padding( + padding: edgeInsetsL12T8R12, + child: AppContentWidth( + maxWidth: appReadingMaxWidth, + child: AppNoticeBanner( + tone: AppNoticeTone.warning, + icon: Icons.lock_clock_outlined, + message: tr.softCloseHint, ), - ], + ), ), ), ), @@ -575,20 +611,32 @@ class _ThreadPageState extends State with SingleTickerProviderStateM ); } + /// Text scaler of the floors: global text scale times the thread content scale setting, capped at + /// [threadContentMaxTextScale]. + TextScaler _threadContentScaler(BuildContext context) { + final threadScale = context.select((bloc) => bloc.state.settingsMap.threadContentScale); + final globalScale = MediaQuery.textScalerOf(context).scale(1); + return TextScaler.linear(math.min(globalScale * threadScale, threadContentMaxTextScale)); + } + + /// Body of the page; everything but the content (which handles the insets per part) stays in the safe area. Widget _buildBody(BuildContext context, ThreadState state) { + Widget safe(Widget child) => SafeArea(bottom: false, child: child); if (state.needLogin) { - return NeedLoginPage( - backUri: GoRouterState.of(context).uri, - needPop: true, - popCallback: (context) { - context.read().add(ThreadRefreshRequested()); - }, + return safe( + NeedLoginPage( + backUri: GoRouterState.of(context).uri, + needPop: true, + popCallback: (context) { + context.read().add(ThreadRefreshRequested()); + }, + ), ); } else if (!state.havePermission) { if (state.permissionDeniedMessage != null) { - return ErrorCard(child: munchElement(context, state.permissionDeniedMessage!)); + return safe(ErrorCard(child: munchElement(context, state.permissionDeniedMessage!))); } else { - return Center(child: Text(context.t.general.noPermission)); + return safe(AppStateView(icon: Icons.lock_outline, message: context.t.general.noPermission)); } } @@ -596,9 +644,11 @@ class _ThreadPageState extends State with SingleTickerProviderStateM ThreadStatus.initial || ThreadStatus.loading => const CenteredCircularIndicator(), // A failed reload keeps the previous posts (see ThreadBloc); only an empty page gets the retry button. ThreadStatus.failure when state.postList.isNotEmpty => _buildContent(context, state), - ThreadStatus.failure => buildRetryButton(context, () { - context.read().add(ThreadLoadMoreRequested(state.currentPage)); - }), + ThreadStatus.failure => safe( + buildRetryButton(context, () { + context.read().add(ThreadLoadMoreRequested(state.currentPage)); + }), + ), ThreadStatus.success => _buildContent(context, state), }; } @@ -759,7 +809,7 @@ class _ThreadPageState extends State with SingleTickerProviderStateM appBar: ListAppBar( title: title, bottom: PreferredSize( - preferredSize: Size.fromHeight(20 + textScaleExtraBreadHeight), + preferredSize: Size.fromHeight(_breadcrumbsHeight(textScaleExtraBreadHeight)), child: _buildBreadcrumbsRow(state, textScaleExtraBreadHeight, replied: replied), ), showReverseOrderAction: true, @@ -810,7 +860,7 @@ class _ThreadPageState extends State with SingleTickerProviderStateM ], ], ), - body: SafeArea(bottom: false, child: _buildBody(context, state)), + body: _buildBody(context, state), ); }, ), @@ -830,29 +880,22 @@ class _AuthorLookupFailure extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline, size: 48), - sizedBoxW8H8, - Text(context.t.general.failedToLoad, textAlign: TextAlign.center), - sizedBoxW8H8, - Wrap( - spacing: 8, - children: [ - if (Navigator.of(context).canPop()) - OutlinedButton( - onPressed: () => Navigator.of(context).maybePop(), - child: Text(context.t.userBlock.goBack), - ), - FilledButton(onPressed: onRetry, child: Text(context.t.general.retry)), - ], + return AppStateView( + error: true, + icon: Icons.error_outline, + message: context.t.general.failedToLoad, + action: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + if (Navigator.of(context).canPop()) + OutlinedButton( + onPressed: () => Navigator.of(context).maybePop(), + child: Text(context.t.userBlock.goBack), ), - ], - ), + FilledButton(onPressed: onRetry, child: Text(context.t.general.retry)), + ], ), ); } diff --git a/lib/features/thread/v1/widgets/operation_log_card.dart b/lib/features/thread/v1/widgets/operation_log_card.dart index f9ca26b8b..d75494d39 100644 --- a/lib/features/thread/v1/widgets/operation_log_card.dart +++ b/lib/features/thread/v1/widgets/operation_log_card.dart @@ -8,6 +8,7 @@ import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/features/thread/v1/repository/thread_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -31,43 +32,67 @@ class OperationLogCard extends StatelessWidget { return RootPage( DialogPaths.showOperationLog, CustomAlertDialog.future( - title: Text(tr.title), + title: Row( + children: [ + const AppIconTile(Icons.manage_history_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.title)), + ], + ), future: context.read().fetchOperationLog(tid).run(), successBuilder: (context, data) { if (data.isLeft()) { - return Text(context.t.general.failedToLoad); + return AppNoticeBanner(tone: AppNoticeTone.error, message: context.t.general.failedToLoad); } + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // One block per operation: operator (opens the profile), time, and the action in the primary color. final content = data.unwrap().map( - (e) => ListTile( - contentPadding: EdgeInsets.zero, - isThreeLine: true, - leading: GestureDetector( - onTap: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'username': e.username}), - child: HeroUserAvatar(username: e.username, avatarUrl: null, disableHero: true), - ), - title: GestureDetector( - onTap: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'username': e.username}), - child: Text(e.username), - ), - subtitle: Column( + (e) => AppInsetBlock( + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SingleLineText(e.time.yyyyMMDDHHMMSS(), style: Theme.of(context).textTheme.labelMedium), - SingleLineText( - '${e.action}${e.duration != null ? "(${e.duration})" : ""}', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.primary), + GestureDetector( + onTap: () async => + context.pushNamed(ScreenPaths.profile, queryParameters: {'username': e.username}), + child: HeroUserAvatar(username: e.username, avatarUrl: null, disableHero: true), + ), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () async => + context.pushNamed(ScreenPaths.profile, queryParameters: {'username': e.username}), + child: Text( + e.username, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + SingleLineText( + e.time.yyyyMMDDHHMMSS(), + style: textTheme.labelSmall?.copyWith(color: colorScheme.outline), + ), + sizedBoxW4H4, + Text( + '${e.action}${e.duration != null ? "(${e.duration})" : ""}', + style: textTheme.bodyMedium?.copyWith(color: colorScheme.primary), + ), + ], + ), ), ], ), ), ); - return Column(children: content.toList()); + return Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: content.toList(), + ); }, ), ); @@ -77,21 +102,27 @@ class OperationLogCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - shape: const OutlineInputBorder(borderSide: BorderSide.none), + // A tappable inset row: the latest moderation, the full log behind the chevron. + final colorScheme = Theme.of(context).colorScheme; + return Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + clipBehavior: Clip.antiAlias, child: InkWell( onTap: () async => _showOperationLogDialog(context, tid), child: Padding( - padding: edgeInsetsL12T4R12B4, + padding: edgeInsetsL12T8R12B8, child: Row( children: [ - Icon(Icons.manage_history_outlined, size: 16, color: Theme.of(context).colorScheme.onSurfaceVariant), + Icon(Icons.manage_history_outlined, size: 18, color: colorScheme.primary), sizedBoxW8H8, Expanded( - child: Text(latestAction, style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant)), + child: Text( + latestAction, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), ), + Icon(Icons.chevron_right, size: 18, color: colorScheme.outline), ], ), ), diff --git a/lib/features/thread/v1/widgets/post_list.dart b/lib/features/thread/v1/widgets/post_list.dart index d08196a7c..76c833019 100644 --- a/lib/features/thread/v1/widgets/post_list.dart +++ b/lib/features/thread/v1/widgets/post_list.dart @@ -13,6 +13,7 @@ import 'package:tsdm_client/features/thread/v1/widgets/operation_log_card.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// A widget that retrieve data from network and supports refresh. class PostList extends StatefulWidget { @@ -43,7 +44,7 @@ class PostList extends StatefulWidget { /// Build a list of [Widget]. final Widget Function(BuildContext, Post) widgetBuilder; - /// Use [Divider] instead of [SizedBox] between list items. + /// Floors are separate rounded surfaces; a wider gap between them when true, a thin one otherwise. final bool useDivider; /// List of [Post] content. @@ -220,20 +221,25 @@ class _PostListState extends State with LoggerMixin { itemBuilder: (context, index) { final post = widget.postList[index]; // Each floor paints into its own layer, so scrolling moves layers instead of repainting every card. - final card = RepaintBoundary(child: widget.widgetBuilder(context, post)); + final card = RepaintBoundary(child: _FloorSurface(child: widget.widgetBuilder(context, post))); // Adding or removing the key is a structural change: that floor is rebuilt into a new element on the next // build after `initialPostID` changes (the thread page drops its scroll target one frame after a reload). A // card must therefore not rely on its own `context` across an async gap that may span such a rebuild; the // edit flow takes what it needs before opening the editor (GitHub #76). return post.postID == '${widget.initialPostID}' ? KeyedSubtree(key: _initialPostKey, child: card) : card; }, - separatorBuilder: (context, index) => widget.useDivider ? const Divider(thickness: 0.5) : sizedBoxW4H4, + separatorBuilder: (context, index) => widget.useDivider ? sizedBoxW8H8 : sizedBoxW4H4, ); } @override Widget build(BuildContext context) { - _refreshController.finishLoad(); + // The bloc reloads the thread and this list is rebuilt with the result: both indicators are done by then. The + // refresh one used to be left "processing", so after a pull to refresh a blank of its height stayed above the + // title (feedback 113). + _refreshController + ..finishRefresh() + ..finishLoad(); return EasyRefresh.builder( scrollBehaviorBuilder: (physics) { @@ -274,18 +280,37 @@ class _PostListState extends State with LoggerMixin { const HeaderLocator.sliver(), if (widget.latestModAct != null && widget.latestModAct!.isNotEmpty && widget.threadID != null) SliverToBoxAdapter( - child: Align( - alignment: Alignment.bottomRight, - child: Padding( - padding: edgeInsetsL12T4R12B4, + // Same column and side room as the floors below it. + child: Padding( + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width < 600 ? 6 : 16, + 8, + MediaQuery.sizeOf(context).width < 600 ? 6 : 16, + 0, + ), + child: AppContentWidth( + maxWidth: appReadingMaxWidth, child: OperationLogCard(latestAction: widget.latestModAct!, tid: widget.threadID!), ), ), ), - SliverPadding( - padding: edgeInsetsL12T4R12B4, - sliver: SliverToBoxAdapter( - child: Text(widget.title ?? '', style: Theme.of(context).textTheme.titleLarge), + SliverToBoxAdapter( + child: AppContentWidth( + maxWidth: appReadingMaxWidth, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + // Full width: the column centres its child, so a short title used to sit in the middle while a + // long one wrapped from the left (feedback 113). Every title starts at the left. + child: SizedBox( + width: double.infinity, + child: Text( + widget.title ?? '', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold, height: 1.35), + ), + ), + ), ), ), _buildPostList(), @@ -297,6 +322,26 @@ class _PostListState extends State with LoggerMixin { } } +/// A floor of the thread as a rounded surface, at most [appReadingMaxWidth] wide so lines stay readable on desktop. +class _FloorSurface extends StatelessWidget { + const _FloorSurface({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + // Phones keep most of the width for the text. + final side = MediaQuery.sizeOf(context).width < 600 ? 6.0 : 16.0; + return Padding( + padding: EdgeInsets.symmetric(horizontal: side), + child: AppContentWidth( + maxWidth: appReadingMaxWidth, + child: Card(margin: EdgeInsets.zero, shape: appSurfaceShape(context), child: child), + ), + ); + } +} + /// Delegate to build a persistent sliver app bar. class SliverAppBarPersistentDelegate extends SliverPersistentHeaderDelegate { /// Constructor. diff --git a/lib/features/thread/v2/view/thread_page_v2.dart b/lib/features/thread/v2/view/thread_page_v2.dart index 5d4d2862a..7c737630e 100644 --- a/lib/features/thread/v2/view/thread_page_v2.dart +++ b/lib/features/thread/v2/view/thread_page_v2.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:tsdm_client/features/thread/v1/view/thread_page.dart'; -/// V2 page for each thread. +/// V2 route of a thread. /// -/// -/// Find post is not supported yet. +/// The v2 reader was never finished and only showed a debug text; the route now opens the same reader as +/// [ThreadPage] with the same parameters, so a v2 link shows the real thread with the redesigned floors. class ThreadPageV2 extends StatefulWidget { /// Constructor. const ThreadPageV2({ @@ -65,9 +66,13 @@ class ThreadPageV2 extends StatefulWidget { class _ThreadPageV2State extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('THREAD PAGE V2')), - body: Center(child: Text('id=${widget.id}, page=${widget.pageNumber}')), + return ThreadPage( + threadID: widget.id, + findPostID: widget.pid?.toString(), + pageNumber: widget.pageNumber, + overrideReverseOrder: widget.overrideReverseOrder, + overrideWithExactOrder: widget.overrideWithExactOrder, + onlyVisibleUid: widget.onlyVisibleUid, ); } } diff --git a/lib/features/thread_visit_history/view/thread_visit_history_page.dart b/lib/features/thread_visit_history/view/thread_visit_history_page.dart index 6b56c5672..e6b1fd8da 100644 --- a/lib/features/thread_visit_history/view/thread_visit_history_page.dart +++ b/lib/features/thread_visit_history/view/thread_visit_history_page.dart @@ -8,6 +8,7 @@ import 'package:tsdm_client/features/thread_visit_history/widgets/thread_visit_h import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; import 'package:tsdm_client/widgets/tips.dart'; @@ -55,42 +56,46 @@ class _ThreadVisitHistoryPageState extends State { duplicated.contains(name) ? tr.accountWithUid(username: name, uid: uid) : name; final label = _selectedUid == null ? tr.allAccounts : labelOf(_selectedUid!, _selectedUsername); - return Padding( - padding: edgeInsetsL12T4R12B4, - child: Align( - alignment: Alignment.centerLeft, - child: PopupMenuButton( - key: _menuKey, - tooltip: tr.filterAccount, - initialValue: _selectedUid ?? _allAccounts, - onSelected: (value) => _select(value == _allAccounts ? null : value, accounts), - itemBuilder: (context) => [ - CheckedPopupMenuItem(value: _allAccounts, checked: _selectedUid == null, child: Text(tr.allAccounts)), - for (final MapEntry(key: uid, value: name) in accounts.entries) - CheckedPopupMenuItem( - value: uid, - checked: uid == _selectedUid, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), - Text( - tr.accountUid(uid: uid), - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), - ), - ], + // Same start as the cards: the horizontal padding of the list below (at least the page padding, centered on wide + // windows), measured in the same safe area. + return AppCenteredList( + builder: (context, side, _) => Padding( + padding: side.copyWith(top: 4, bottom: 4), + child: Align( + alignment: Alignment.centerLeft, + child: PopupMenuButton( + key: _menuKey, + tooltip: tr.filterAccount, + initialValue: _selectedUid ?? _allAccounts, + onSelected: (value) => _select(value == _allAccounts ? null : value, accounts), + itemBuilder: (context) => [ + CheckedPopupMenuItem(value: _allAccounts, checked: _selectedUid == null, child: Text(tr.allAccounts)), + for (final MapEntry(key: uid, value: name) in accounts.entries) + CheckedPopupMenuItem( + value: uid, + checked: uid == _selectedUid, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + tr.accountUid(uid: uid), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.outline), + ), + ], + ), ), + ], + // The chip handles the tap and opens the menu anchored at itself, so the ripple keeps the chip shape. + child: ActionChip( + avatar: const Icon(Icons.filter_list), + label: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), ), - ], - // The chip handles the tap and opens the menu anchored at itself, so the ripple keeps the chip shape. - child: ActionChip( - avatar: const Icon(Icons.filter_list), - label: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 220), - child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + onPressed: () => _menuKey.currentState?.showButtonMenu(), ), - onPressed: () => _menuKey.currentState?.showButtonMenu(), ), ), ), @@ -131,16 +136,18 @@ class _ThreadVisitHistoryPageState extends State { return Scaffold( appBar: AppBar(title: Text(tr.title), bottom: Tips(tr.localOnlyTip, sizePreferred: true)), - body: Column( - children: [ - _buildFilter(context, accounts), - Expanded( - child: SafeArea( - top: false, + // The account filter shares the safe area of the list: outside of it the chip sat under the cutout in + // landscape, out of line with the tip and the cards (feedback 110). + body: SafeArea( + top: false, + child: Column( + children: [ + _buildFilter(context, accounts), + Expanded( child: AnimatedSwitcher(duration: duration200, child: body), ), - ), - ], + ], + ), ), ); }, @@ -193,26 +200,21 @@ class _BodyState extends State<_Body> { padding: edgeInsetsL12T4R12, children: [ sizedBoxW32H32, - Center( - child: Text( - widget.emptyText, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ), - ), + AppStateView(icon: Icons.history_outlined, message: widget.emptyText), ], ); } - return ListView.separated( - controller: _scrollController, - physics: physics, - padding: edgeInsetsL12T4R12, - itemCount: widget.models.length, - itemBuilder: (context, index) { - return ThreadVisitHistoryCard(widget.models[index]); - }, - separatorBuilder: (_, _) => sizedBoxW4H4, + return AppCenteredList( + builder: (context, side, _) => ListView.separated( + controller: _scrollController, + physics: physics, + padding: side.copyWith(top: 4, bottom: 16), + itemCount: widget.models.length, + itemBuilder: (context, index) { + return ThreadVisitHistoryCard(widget.models[index]); + }, + separatorBuilder: (_, _) => appListSeparator, + ), ); }, ); diff --git a/lib/features/thread_visit_history/widgets/thread_visit_history_card.dart b/lib/features/thread_visit_history/widgets/thread_visit_history_card.dart index 10a1d7aed..fc441c102 100644 --- a/lib/features/thread_visit_history/widgets/thread_visit_history_card.dart +++ b/lib/features/thread_visit_history/widgets/thread_visit_history_card.dart @@ -4,9 +4,11 @@ import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/date_time.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; -import 'package:tsdm_client/themes/widget_themes.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Card to show a record of thread visit history. +/// +/// Title first, then author, forum and visit time as pills that wrap on narrow windows or with a large font. class ThreadVisitHistoryCard extends StatelessWidget { /// Constructor. const ThreadVisitHistoryCard(this.model, {super.key}); @@ -16,42 +18,36 @@ class ThreadVisitHistoryCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async => context.pushNamed(ScreenPaths.threadV1, queryParameters: {'tid': '${model.threadId}'}), - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Text(model.threadTitle, style: Theme.of(context).textTheme.titleMedium), - ), - sizedBoxW12H12, - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( + return AppSurface( + padding: edgeInsetsL12T12R12B12, + onTap: () async => context.pushNamed(ScreenPaths.threadV1, queryParameters: {'tid': '${model.threadId}'}), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AppIconTile(Icons.history_outlined, size: 36), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + model.threadTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 6, children: [ - const Icon(Icons.person_outline, size: smallIconSize), - sizedBoxW4H4, - Text(model.username), - sizedBoxW12H12, - const Icon(Icons.forum_outlined, size: smallIconSize), - sizedBoxW4H4, - Text(model.forumName), - sizedBoxW12H12, - const Icon(Icons.access_time_outlined, size: smallTextSize), - sizedBoxW4H4, - Text(model.visitTime.yyyyMMDDHHMMSS()), + AppInfoPill(icon: Icons.person_outline, label: model.username), + AppInfoPill(icon: Icons.forum_outlined, label: model.forumName), + AppInfoPill(icon: Icons.access_time_outlined, label: model.visitTime.yyyyMMDDHHMMSS()), ], ), - ), - ], + ], + ), ), - ), + ], ), ); } diff --git a/lib/features/title_shop/view/title_shop_page.dart b/lib/features/title_shop/view/title_shop_page.dart index b5ff1441a..d4d9b64a0 100644 --- a/lib/features/title_shop/view/title_shop_page.dart +++ b/lib/features/title_shop/view/title_shop_page.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; @@ -14,9 +15,13 @@ import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/indicator.dart'; +/// Height of a title artwork for one unit of width: the forum's titles are 184x100. +const double titleShopImageRatio = 100 / 184; + /// Native secondary title shop. A bought title is added to My titles and never equipped automatically. class TitleShopPage extends StatefulWidget { /// An injected [controller] is owned by its caller. @@ -63,15 +68,22 @@ class _TitleShopPageState extends State { super.dispose(); } - Widget _image(String? url) => SizedBox( - width: 96, - height: 32, - child: - widget.imageBuilder?.call(url) ?? - (url == null - ? const Icon(Icons.image_not_supported_outlined) - : CachedImage(url, width: 96, height: 32, fit: BoxFit.contain)), - ); + /// Title artwork, [width] wide at the 184x100 ratio of the forum titles, contained and never cropped. + Widget _image(String? url, double width) { + final height = width * titleShopImageRatio; + return AppInsetBlock( + padding: const EdgeInsets.all(8), + child: SizedBox( + width: width, + height: height, + child: + widget.imageBuilder?.call(url) ?? + (url == null + ? Icon(Icons.image_not_supported_outlined, color: Theme.of(context).colorScheme.outline) + : CachedImage(url, width: width, height: height, fit: BoxFit.contain)), + ), + ); + } Future _buy(TitleShopCatalog page, TitleShopItem item) async { if (_dialogOpen || !_cubit.canPurchase(page, item)) return; @@ -85,17 +97,36 @@ class _TitleShopPageState extends State { builder: (context) => AlertDialog( // Large text or a long name scrolls the title and content; Cancel and Buy stay pinned below. scrollable: true, - title: Text(tr.purchaseTitle), + title: Row( + children: [ + const AppIconTile(Icons.shopping_cart_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.purchaseTitle)), + ], + ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.name, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), + // Fixed size (no LayoutBuilder): the dialog lays out with intrinsic sizes. + Center(child: _image(item.imageUrl, 138)), + sizedBoxW12H12, + Text( + item.name, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + sizedBoxW4H4, + _PriceLine( + text: tr.idAndPrice(id: item.id, price: item.price), + ), + sizedBoxW12H12, // Prefer the server's own sentence: it carries the exact price and currency. - Text(item.form?.confirmText ?? tr.purchaseConfirm(price: item.price)), - const SizedBox(height: 8), - Text(tr.notEquipped, style: Theme.of(context).textTheme.bodySmall), + AppInsetBlock( + outlined: true, + child: Text(item.form?.confirmText ?? tr.purchaseConfirm(price: item.price)), + ), + sizedBoxW12H12, + AppNoticeBanner(message: tr.notEquipped), ], ), actions: [ @@ -113,70 +144,180 @@ class _TitleShopPageState extends State { } Widget _result(TitlePurchaseResult result, TranslationsTitleShopEn tr) { - final colors = Theme.of(context).colorScheme; - final (text, color) = switch (result.outcome) { - TitlePurchaseOutcome.purchased => (tr.purchased(name: result.name), colors.primaryContainer), - TitlePurchaseOutcome.rejected => (tr.rejected(name: result.name), colors.errorContainer), - TitlePurchaseOutcome.unconfirmed => (tr.unconfirmed(name: result.name), colors.tertiaryContainer), - TitlePurchaseOutcome.termsChanged => (tr.termsChanged(name: result.name), colors.tertiaryContainer), - TitlePurchaseOutcome.unavailable => (tr.unavailableNow(name: result.name), colors.surfaceContainerHighest), + final (text, tone, icon) = switch (result.outcome) { + TitlePurchaseOutcome.purchased => ( + tr.purchased(name: result.name), + AppNoticeTone.info, + Icons.check_circle_outline, + ), + TitlePurchaseOutcome.rejected => (tr.rejected(name: result.name), AppNoticeTone.error, Icons.error_outline), + TitlePurchaseOutcome.unconfirmed => ( + tr.unconfirmed(name: result.name), + AppNoticeTone.warning, + Icons.help_outline, + ), + TitlePurchaseOutcome.termsChanged => ( + tr.termsChanged(name: result.name), + AppNoticeTone.warning, + Icons.price_change_outlined, + ), + TitlePurchaseOutcome.unavailable => ( + tr.unavailableNow(name: result.name), + AppNoticeTone.warning, + Icons.remove_shopping_cart_outlined, + ), }; - return Card( - key: const ValueKey('title-shop-result'), - color: color, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(text), - if (result.message?.isNotEmpty ?? false) ...[const SizedBox(height: 4), Text(result.message!)], - ], - ), + final message = result.message; + return Padding( + padding: const EdgeInsets.only(bottom: appSurfaceGap), + child: AppNoticeBanner( + key: const ValueKey('title-shop-result'), + tone: tone, + icon: icon, + // The forum's own message, when any, stays selectable under the app's summary. + title: message?.isNotEmpty ?? false ? text : null, + message: message?.isNotEmpty ?? false ? message! : text, + selectable: message?.isNotEmpty ?? false, ), ); } - Widget _item(TitleShopState state, TitleShopCatalog page, TitleShopItem item, TranslationsTitleShopEn tr) { - final status = switch (item.status) { - TitleShopStatus.purchasable when state.purchasingId == item.id => const SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(strokeWidth: 2), + Widget _status(TitleShopState state, TitleShopCatalog page, TitleShopItem item, TranslationsTitleShopEn tr) { + final colorScheme = Theme.of(context).colorScheme; + return switch (item.status) { + TitleShopStatus.purchasable when state.purchasingId == item.id => const Padding( + padding: EdgeInsets.all(8), + child: SizedBox.square(dimension: 24, child: CircularProgressIndicator(strokeWidth: 2)), ), TitleShopStatus.purchasable => FilledButton.tonalIcon( onPressed: _dialogOpen || state.purchasingId != null ? null : () => _buy(page, item), icon: const Icon(Icons.shopping_cart_outlined), label: Text(tr.purchase), ), - TitleShopStatus.owned => Chip(avatar: const Icon(Icons.check, size: 18), label: Text(tr.owned)), - TitleShopStatus.unavailable => Text(item.statusText ?? tr.unavailable), + TitleShopStatus.owned => _StatusPill( + icon: Icons.check, + label: tr.owned, + color: colorScheme.primaryContainer, + foreground: colorScheme.onPrimaryContainer, + ), + TitleShopStatus.unavailable => _StatusPill( + icon: Icons.block_outlined, + label: item.statusText ?? tr.unavailable, + color: colorScheme.surfaceContainerHighest, + foreground: colorScheme.onSurfaceVariant, + ), }; - return Card( + } + + Widget _item(TitleShopState state, TitleShopCatalog page, TitleShopItem item, TranslationsTitleShopEn tr) { + final textTheme = Theme.of(context).textTheme; + final status = _status(state, page, item, tr); + final details = [ + Text(item.name, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + sizedBoxW4H4, + _PriceLine( + text: tr.idAndPrice(id: item.id, price: item.price), + ), + ]; + return AppSurface( key: ValueKey('title-${item.id}'), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( + child: LayoutBuilder( + builder: (context, constraints) { + // Artwork beside the text while both stay readable, above it on narrow screens or with large text. + final scale = MediaQuery.textScalerOf(context).scale(1); + if (constraints.maxWidth >= 400 * scale) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _image(item.imageUrl, 138), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [...details, sizedBoxW12H12, status], + ), + ), + ], + ); + } + final imageWidth = (constraints.maxWidth - 16).clamp(0.0, 184.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center(child: _image(item.imageUrl, imageWidth)), + sizedBoxW12H12, + ...details, + sizedBoxW12H12, + Align(alignment: AlignmentDirectional.centerStart, child: status), + ], + ); + }, + ), + ); + } + + Widget _header(TitleShopCatalog page) { + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(bottom: appSurfaceGap), + child: AppSurface( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _image(item.imageUrl), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (page.balance case final balance?) + Row( children: [ - Text(item.name, style: Theme.of(context).textTheme.titleMedium), - Text(tr.idAndPrice(id: item.id, price: item.price)), - const SizedBox(height: 8), - status, + const AppIconTile(Icons.account_balance_wallet_outlined, size: 36), + sizedBoxW12H12, + Expanded( + child: Text(balance, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + ), ], ), - ), + if (page.balance != null && page.intro.isNotEmpty) sizedBoxW12H12, + for (final (index, line) in page.intro.indexed) + Padding( + padding: EdgeInsets.only(top: index == 0 ? 0 : 4), + child: Text(line, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + ), ], ), ), ); } + Widget _pager(TitleShopState state, TitleShopCatalog page, TranslationsTitleShopEn tr) => Padding( + padding: const EdgeInsets.only(top: appSurfaceGap), + child: Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + TextButton.icon( + onPressed: page.previousUrl == null || state.purchasingId != null + ? null + : () => _cubit.load(page.previousUrl), + icon: const Icon(Icons.chevron_left), + label: Text(tr.previous), + ), + AppInsetBlock( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Text(tr.page(number: page.page)), + ), + TextButton( + onPressed: page.nextUrl == null || state.purchasingId != null ? null : () => _cubit.load(page.nextUrl), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [Text(tr.next), sizedBoxW4H4, const Icon(Icons.chevron_right, size: 18)], + ), + ), + ], + ), + ); + @override Widget build(BuildContext context) => BlocBuilder( bloc: _cubit, @@ -188,68 +329,72 @@ class _TitleShopPageState extends State { if (state.loading) { body = const CenteredCircularIndicator(); } else if (state.loginRequired) { - body = Center( - child: TextButton( + body = AppStateView( + icon: Icons.login, + message: tr.loginRequired, + action: FilledButton.tonalIcon( onPressed: () async { await context.pushNamed(ScreenPaths.login); if (mounted) await _cubit.load(); }, - child: Text(tr.loginRequired), + icon: const Icon(Icons.login), + label: Text(context.t.loginPage.login), ), ); } else if (state.failed || page == null) { - body = ListView( - padding: const EdgeInsets.all(12), - children: [ - if (result != null) _result(result, tr), - buildRetryButton(context, () => unawaited(_cubit.load()), message: context.t.general.failedToLoad), - ], + body = AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 12), + children: [ + if (result != null) _result(result, tr), + buildRetryButton(context, () => unawaited(_cubit.load()), message: context.t.general.failedToLoad), + ], + ), ); } else { + final items = page.items; body = RefreshIndicator( onRefresh: _cubit.load, - child: ListView( - key: ValueKey(state.url), - padding: const EdgeInsets.all(12), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - if (result != null) _result(result, tr), - for (final line in page.intro) Padding(padding: const EdgeInsets.only(bottom: 4), child: Text(line)), - if (page.balance case final balance?) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text(balance, style: Theme.of(context).textTheme.labelLarge), - ), - if (!page.supported) - Padding( - padding: const EdgeInsets.all(24), - child: Text(page.message?.isNotEmpty ?? false ? page.message! : tr.unsupported), - ) - else if (page.items.isEmpty) - Padding(padding: const EdgeInsets.all(24), child: Text(tr.empty)), - for (final item in page.items) _item(state, page, item, tr), - if (page.supported) - Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 12, - children: [ - TextButton( - onPressed: page.previousUrl == null || state.purchasingId != null - ? null - : () => _cubit.load(page.previousUrl), - child: Text(tr.previous), - ), - Text(tr.page(number: page.page)), - TextButton( - onPressed: page.nextUrl == null || state.purchasingId != null - ? null - : () => _cubit.load(page.nextUrl), - child: Text(tr.next), + child: AppCenteredList( + builder: (context, padding, width) { + final columns = appColumnsFor(width); + final gap = width < 600 ? appSurfaceGapCompact : appSurfaceGap; + return ListView( + key: ValueKey(state.url), + padding: padding.copyWith(top: 12, bottom: 12), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + if (result != null) _result(result, tr), + if (page.balance != null || page.intro.isNotEmpty) _header(page), + if (!page.supported) + AppStateView( + icon: Icons.storefront_outlined, + message: page.message?.isNotEmpty ?? false ? page.message! : tr.unsupported, + scrollable: false, + action: TextButton.icon( + onPressed: () async => context.dispatchAsUrl(state.url, external: true), + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(tr.openBrowser), + ), + ) + else if (items.isEmpty) + AppStateView(icon: Icons.storefront_outlined, message: tr.empty, scrollable: false), + for (var row = 0; row < appRowCount(items.length, columns); row++) + Padding( + padding: EdgeInsets.only(top: row == 0 ? 0 : gap), + child: AppColumnsRow( + row: row, + columns: columns, + count: items.length, + gap: gap, + itemBuilder: (context, index) => _item(state, page, items[index], tr), + ), ), - ], - ), - ], + if (page.supported) _pager(state, page, tr), + ], + ); + }, ), ); } @@ -274,3 +419,57 @@ class _TitleShopPageState extends State { }, ); } + +/// ID and price of a title, never shortened: the price is what the user pays. +class _PriceLine extends StatelessWidget { + const _PriceLine({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon(Icons.sell_outlined, size: 16, color: colorScheme.primary), + ), + sizedBoxW4H4, + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + ], + ); + } +} + +/// Owned or unavailable state of a title, in place of the buy button. +class _StatusPill extends StatelessWidget { + const _StatusPill({required this.icon, required this.label, required this.color, required this.foreground}); + + final IconData icon; + final String label; + final Color color; + final Color foreground; + + @override + Widget build(BuildContext context) => AppInsetBlock( + color: color, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18, color: foreground), + sizedBoxW8H8, + Flexible( + child: Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(color: foreground)), + ), + ], + ), + ); +} diff --git a/lib/features/topics/view/topics_page.dart b/lib/features/topics/view/topics_page.dart index cc45198ea..97ec58bbe 100644 --- a/lib/features/topics/view/topics_page.dart +++ b/lib/features/topics/view/topics_page.dart @@ -17,9 +17,19 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart'; import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; import 'package:tsdm_client/utils/retry_button.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/card/forum_card.dart'; import 'package:tsdm_client/widgets/indicator.dart'; +/// Widest the rows of the topics page grow on desktop windows, shared with every forum list: [forumCardListMaxWidth]. +const double topicsPageMaxWidth = forumCardListMaxWidth; + +/// Width the topics page must really have to show its forum cards large on desktop: [forumCardListLargeWidth]. +const double topicsPageLargeCardWidth = forumCardListLargeWidth; + +/// Whether the topics page shows large cards in a page of [width] on [platform] (desktop only). +bool topicsPageUsesLargeCards(double width, TargetPlatform platform) => forumCardListUsesLargeCards(width, platform); + /// App topic page. class TopicsPage extends StatefulWidget { /// Constructor. @@ -93,14 +103,29 @@ class _TopicsPageState extends State with TickerProviderStateMixin { _tabScrollControllers.putIfAbsent(e.name, ScrollController.new); final head = e.moderators.isEmpty ? 0 : 1; - return ListView.separated( - controller: _tabScrollControllers[e.name], - padding: edgeInsetsL12T4R12, - itemCount: e.forumList.length + head, - itemBuilder: (context, index) => head == 1 && index == 0 - ? GroupModeratorsRow(moderators: e.moderators) - : ForumCard(e.forumList[index - head]), - separatorBuilder: (context, index) => sizedBoxW4H4, + final forums = e.forumList; + // One column on phones, two on wide windows; rows stay centered. Desktop windows get a wider content area + // with large cards, see forumCardListLayout; phones keep the compact cards at any width. + return AppCenteredList( + builder: (context, _, width) { + final layout = forumCardListLayout(width, Theme.of(context).platform); + final large = layout.large; + return ListView.separated( + controller: _tabScrollControllers[e.name], + padding: layout.side.copyWith(top: large ? 16 : 8, bottom: large ? 24 : 16), + itemCount: appRowCount(forums.length, layout.columns) + head, + itemBuilder: (context, index) => head == 1 && index == 0 + ? GroupModeratorsRow(moderators: e.moderators, large: large) + : AppColumnsRow( + row: index - head, + columns: layout.columns, + count: forums.length, + gap: layout.gap, + itemBuilder: (_, i) => ForumCard(forums[i], large: large), + ), + separatorBuilder: (context, index) => layout.separator, + ); + }, ); }).toList(); @@ -177,7 +202,10 @@ class _TopicsPageState extends State with TickerProviderStateMixin { header: const MaterialHeader(), child: const CenteredCircularIndicator(), ), - TopicsStatus.failed => buildRetryButton(context, () => context.read().add(const TopicsRefreshRequested())), + TopicsStatus.failed => buildRetryButton( + context, + () => context.read().add(const TopicsRefreshRequested()), + ), TopicsStatus.success when state.forumGroupList.isNotEmpty => _buildContent(context, state), TopicsStatus.success => NeedLoginPage( backUri: GoRouterState.of(context).uri, diff --git a/lib/features/topics/widgets/group_moderators_row.dart b/lib/features/topics/widgets/group_moderators_row.dart index a209ad6f4..6d81944d1 100644 --- a/lib/features/topics/widgets/group_moderators_row.dart +++ b/lib/features/topics/widgets/group_moderators_row.dart @@ -3,44 +3,61 @@ import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// The moderators of a forum group, as the site lists them in the group header ("分区版主: a, b"), GitHub #22. /// /// Tapping a name opens that user's profile. class GroupModeratorsRow extends StatelessWidget { /// Constructor. - const GroupModeratorsRow({required this.moderators, super.key}); + const GroupModeratorsRow({required this.moderators, this.large = false, super.key}); /// Moderator names, in the site's order. final List moderators; + /// Bigger icon, label and chips, matching the large forum cards of the wide topics page. + final bool large; + @override Widget build(BuildContext context) { if (moderators.isEmpty) { return const SizedBox.shrink(); } final scheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // A tinted block above the forum cards of the group (the list already gives the side padding): shield icon, + // label, one rounded chip per name. return Padding( - padding: edgeInsetsL12T4R12, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 6, - runSpacing: 4, - children: [ - Text( - context.t.topicsPage.moderators, - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: scheme.secondary), - ), - for (final name in moderators) - ActionChip( - label: Text(name), - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - labelStyle: Theme.of(context).textTheme.labelMedium, - onPressed: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'username': name}), + padding: EdgeInsets.only(bottom: large ? 12 : 8), + child: AppInsetBlock( + outlined: true, + padding: large ? const EdgeInsets.symmetric(horizontal: 16, vertical: 12) : edgeInsetsL12T8R12B8, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: large ? 8 : 6, + runSpacing: large ? 6 : 4, + children: [ + Icon(Icons.admin_panel_settings_outlined, size: large ? 22 : 18, color: scheme.primary), + Text( + context.t.topicsPage.moderators, + style: (large ? textTheme.titleSmall : textTheme.labelMedium)?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.bold, + ), ), - ], + for (final name in moderators) + ActionChip( + avatar: Icon(Icons.person_outline, size: large ? 18 : 16, color: scheme.onSurfaceVariant), + label: Text(name), + visualDensity: large ? VisualDensity.standard : VisualDensity.compact, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + labelStyle: large ? textTheme.labelLarge : textTheme.labelMedium, + onPressed: () async => + context.pushNamed(ScreenPaths.profile, queryParameters: {'username': name}), + ), + ], + ), ), ); } diff --git a/lib/features/update/view/local_changelog_page.dart b/lib/features/update/view/local_changelog_page.dart index 0ab5e05fa..7bc6d12d7 100644 --- a/lib/features/update/view/local_changelog_page.dart +++ b/lib/features/update/view/local_changelog_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Page showing changelog bundled with app. @@ -15,20 +16,31 @@ class LocalChangelogPage extends StatelessWidget { final tr = context.t.settingsPage.othersSection; return Scaffold( appBar: AppBar(title: Text(tr.changelog)), - body: FutureBuilder( - future: compute(readChangelogContent, ''), - builder: (context, snapshot) { - if (snapshot.hasError) { - // Unreachable. - return Text('error: ${snapshot.error}'); - } + body: SafeArea( + top: false, + bottom: false, + child: FutureBuilder( + future: compute(readChangelogContent, ''), + builder: (context, snapshot) { + if (snapshot.hasError) { + // Unreachable. + return AppStateView(icon: Icons.error_outline, error: true, message: 'error: ${snapshot.error}'); + } - if (!snapshot.hasData) { - return const CenteredCircularIndicator(); - } + if (!snapshot.hasData) { + return const CenteredCircularIndicator(); + } - return Markdown(data: snapshot.data!); - }, + // Reading width on wide windows; the markdown keeps its own scroll view and the full width scrollbar. + return AppCenteredList( + maxWidth: appReadingMaxWidth, + builder: (context, padding, _) => Markdown( + data: snapshot.data!, + padding: padding.copyWith(top: 16, bottom: 24 + MediaQuery.paddingOf(context).bottom), + ), + ); + }, + ), ), ); } diff --git a/lib/features/update/view/update_page.dart b/lib/features/update/view/update_page.dart index 82dcc210a..736936189 100644 --- a/lib/features/update/view/update_page.dart +++ b/lib/features/update/view/update_page.dart @@ -1,12 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/features/update/cubit/update_cubit.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; -import 'package:tsdm_client/widgets/tips.dart'; +import 'package:tsdm_client/utils/git_info.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/section_list_tile.dart'; import 'package:url_launcher/url_launcher.dart'; /// Page for app update. @@ -19,6 +24,92 @@ class UpdatePage extends StatefulWidget { } class _UpdatePageState extends State { + /// Current version and the check button; the source tip used to sit in a fixed 40 pixels app bar strip that cut it + /// at large text scales. + Widget _buildVersionSurface(BuildContext context, UpdateCubitState state) { + final tr = context.t.updatePage; + final textTheme = Theme.of(context).textTheme; + final colorScheme = Theme.of(context).colorScheme; + return AppSurface( + padding: edgeInsetsL16T16R16B16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const AppIconTile(Icons.system_update_outlined, size: 48), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.aboutPage.version, + style: textTheme.labelMedium?.copyWith(color: colorScheme.outline), + ), + Text(appFullVersion, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)), + ], + ), + ), + ], + ), + sizedBoxW12H12, + Text(tr.sourceTip, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), + sizedBoxW12H12, + Align( + alignment: AlignmentDirectional.centerStart, + child: FilledButton.tonalIcon( + icon: state.loading ? sizedCircularProgressIndicator : const Icon(Icons.refresh_outlined), + label: Text(tr.checkLatest), + onPressed: state.loading ? null : () async => context.read().checkUpdate(), + ), + ), + ], + ), + ); + } + + /// Result of the last check, only what the cubit holds: nothing before a check. + /// + /// The app wide update dialog is not shown on this page, so a newer version is described here with its changelog. + Widget? _buildResult(BuildContext context, UpdateCubitState state) { + final tr = context.t.updatePage; + if (state.loading) { + return null; + } + final info = state.latestVersionInfo; + if (info == null) { + return state.notice ? AppNoticeBanner(message: tr.failed, tone: AppNoticeTone.error) : null; + } + final current = appVersion.split('+').last.parseToInt() ?? 0; + if (info.versionCode <= current) { + return AppNoticeBanner(message: tr.alreadyLatest, icon: Icons.check_circle_outline); + } + return AppSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AppNoticeBanner( + title: tr.availableDialog.title, + message: tr.availableDialog.versionUpgrade( + oldVersion: appVersion.split('+').first, + newVersion: info.version, + ), + icon: Icons.new_releases_outlined, + ), + if (info.changelog.isNotEmpty) ...[ + AppSectionHeader(tr.availableDialog.changelog, icon: Icons.history_outlined), + AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: MarkdownBody(data: info.changelog), + ), + ], + ], + ), + ); + } + @override Widget build(BuildContext context) { final tr = context.t.updatePage; @@ -35,21 +126,43 @@ class _UpdatePageState extends State { ), ), ], - bottom: Tips(tr.sourceTip, sizePreferred: true), ), - body: ListView( - children: [ - ListTile( - leading: const Icon(Icons.campaign_outlined), - title: Text(tr.announcementThread), - onTap: () async => context.dispatchAsUrl('forum.php?mod=viewthread&tid=1265238'), - ), - ListTile( - leading: Icon(MdiIcons.github), - title: const Text('GitHub'), - onTap: () async => launchUrl(Uri.parse(upgradeGithubReleaseUrl), mode: LaunchMode.externalApplication), - ), - ], + body: SafeArea( + top: false, + child: BlocBuilder( + builder: (context, state) { + final result = _buildResult(context, state); + return AppCenteredList( + maxWidth: appFormMaxWidth, + builder: (context, padding, _) => ListView( + padding: padding.copyWith(top: 12, bottom: 24), + children: [ + _buildVersionSurface(context, state), + if (result != null) ...[const SizedBox(height: appSurfaceGap), result], + const SizedBox(height: appSurfaceGap), + AppTileGroup( + children: [ + SectionListTile( + leading: const Icon(Icons.campaign_outlined), + title: Text(tr.announcementThread), + trailing: const Icon(Icons.chevron_right), + onTap: () async => context.dispatchAsUrl('forum.php?mod=viewthread&tid=1265238'), + ), + SectionListTile( + leading: Icon(MdiIcons.github), + title: const Text('GitHub'), + subtitle: const Text(upgradeGithubReleaseUrl), + trailing: const Icon(Icons.open_in_new), + onTap: () async => + launchUrl(Uri.parse(upgradeGithubReleaseUrl), mode: LaunchMode.externalApplication), + ), + ], + ), + ], + ), + ); + }, + ), ), ); } diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 74deba6c4..c16f8d86b 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -162,8 +162,14 @@ }, "navigation": { "homepage": "Home", - "topics": "Topics", + "topics": "Forums", + "activities": "Activities", + "notice": "Notifications", "profile": "Profile", + "moreSection": "More", + "titleShop": "Title shop", + "medalCenter": "Medal center", + "bank": "Bank", "settings": "Settings" }, "home": { @@ -176,6 +182,8 @@ "history": "History", "notice": "Notification", "checkin": "Checkin", + "checkedIn": "Checked in", + "checkedInHint": "This app recorded a check-in of this account today", "myThread": "My thread", "latestThread": "Latest thread", "favorite": "Favorites" @@ -191,10 +199,31 @@ "today": "Today", "yesterday": "Yesterday", "threads": "Posts" - } + }, + "greeting": { + "morning": "Good morning, ${name}", + "afternoon": "Good afternoon, ${name}", + "evening": "Good evening, ${name}", + "night": "Still up, ${name}?" + }, + "todayPosts": "${count} new posts today", + "forumActivity": "Forum activity", + "guideTitle": "Forum guide", + "tools": "My tools", + "supportNote": "Entirely optional: every feature stays available without it" + }, + "medalTitleHub": { + "title": "Medals & titles", + "entryDescription": "Medal centre, my titles and the title shop", + "currentTitle": "Current title", + "noTitle": "No secondary title in use", + "loadFailed": "Could not read your current title", + "medalCenterDescription": "Browse medals and how to get them", + "myTitlesDescription": "Use, switch or unset the titles you own", + "titleShopDescription": "Browse titles and prices, every purchase asks for confirmation" }, "topicPage": { - "title": "Topics", + "title": "Forums", "threads": "Threads: ", "posts": "Posts: ", "today": "Today: ", @@ -254,6 +283,7 @@ "threadType": "Daydream" }, "showHelpTip": "Show tips on thread card", + "preview": "Preview", "attrs": { "help": "What the icons mean", "closed": { @@ -309,6 +339,11 @@ "textScaleFactor": { "title": "Text scale", "dialogTitle": "Text scale factor" + }, + "threadContentScale": { + "title": "Thread content scale", + "detail": "Extra scale for post content in thread page, on top of text scale", + "dialogTitle": "Thread content scale factor" } }, "windowSection": { @@ -437,6 +472,7 @@ }, "exportData": "Export data", "exportDataDetail": "Without cookies and passwords by default; optionally include them, encrypted with a password of your own", + "backupTitle": "Backup and restore", "exportBackup": { "title": "Export data", "includeAccounts": "Include account logins", @@ -533,7 +569,9 @@ "flutterVersion": "Flutter Version", "dartVersion": "Dart Version", "license": "License", - "copyEnvironmentInfo": "Copy environment info" + "copyEnvironmentInfo": "Copy environment info", + "linksSection": "Links and support", + "buildSection": "Version and build" }, "loginPage": { "title": "Login TSDM", @@ -590,6 +628,7 @@ "profilePage": { "title": "User Profile", "userNodeNotFound": "user info root node not found", + "secondaryTitle": "Secondary title", "checkin": { "title": "Check In", "checkinFailed": "Failed to check in", @@ -795,6 +834,7 @@ "title": "Sync all accounts", "detail": "Fetch notices, personal messages and broadcast messages for every account saved on this device, one account at a time. The unread badge still follows the current account only. Listing consumes the new marker on the forum side, same as a normal sync.", "running": "Running...", + "finished": "Finished", "empty": "No account is saved on this device", "user": { "waiting": "Waiting", @@ -925,7 +965,12 @@ "tooltip": "Daily red packet", "claimed": "Daily red packet: you got ${amount} ${unit}", "alreadyClaimed": "Already claimed today, come back tomorrow", - "failed": "Failed to claim: ${err}" + "failed": "Failed to claim: ${err}", + "claimedToday": "Packet claimed", + "unavailable": "No packet now", + "unavailableHint": "The homepage offers no red packet right now (maybe already claimed or not open yet). Tap to check again.", + "claimedRecordedHint": "This app recorded a claim of this account today; the homepage offers no packet either", + "needLogin": "Log in to claim" } }, "latestThreadPage": { @@ -1044,7 +1089,8 @@ "sizeValue": "${width: int} x ${height: int}", "cacheName": "Cache name", "cacheSize": "Cache size", - "usage": "Usage" + "usage": "Usage", + "fitToScreen": "Fit to screen" }, "forumCard": { "links": "Links", @@ -1696,7 +1742,9 @@ "unset": "Unset", "openTitleShop": "Title shop", "noneActivated": "None secondary title activated", - "activated": "Activated: ${name: String}" + "activated": "Activated: ${name: String}", + "empty": "You do not own any secondary title yet", + "tapToUse": "Tap a title to use it" }, "titleShop": { "title": "Title shop", @@ -1764,7 +1812,14 @@ }, "customTitle": "Custom title", "signature": "Signature", - "timezone": "Timezone" + "timezone": "Timezone", + "sections": { + "basic": "Basic info", + "contact": "Contact", + "aboutMe": "About me", + "forum": "On the forum" + }, + "uploadHint": "Changes stay on this page until you tap the upload button at the top right." }, "urlInfoBottomSheet": { "title": "Url info", @@ -1858,6 +1913,7 @@ "title": "Blocking", "manageEntry": "Blocking", "localHint": "Blocked users (this device): only for the current account on this device. The user is not notified. Their topics, replies and notices with a known sender are hidden. Personal messages are still received and you can open and read them yourself, but this device no longer shows alerts or unread badges for messages from this user. Friends and forum permissions are not changed. Content whose author can not be told may still show, for example topic titles in the homepage guide, favorites and visit history, notices received before this update, and quotes of floors on other pages. Example: account A blocks X; account B on the same device does not inherit it, and the list is still there when you switch back to A.", + "localTitle": "Blocked users (this device)", "block": "Block this user (this device)", "unblock": "Unblock", "blockConfirmTitle": "Block ${name}?", diff --git a/lib/i18n/zh-CN.i18n.json b/lib/i18n/zh-CN.i18n.json index 6d15c36c0..96921de69 100644 --- a/lib/i18n/zh-CN.i18n.json +++ b/lib/i18n/zh-CN.i18n.json @@ -162,8 +162,14 @@ }, "navigation": { "homepage": "主页", - "topics": "分区", + "topics": "版块", + "activities": "活动", + "notice": "通知", "profile": "我的", + "moreSection": "更多功能", + "titleShop": "称号商店", + "medalCenter": "勋章中心", + "bank": "银行", "settings": "设置" }, "home": { @@ -176,6 +182,8 @@ "history": "历史", "notice": "消息", "checkin": "签到", + "checkedIn": "已签到", + "checkedInHint": "本 App 记录到这个账号今天已签到", "myThread": "我的帖子", "latestThread": "最新帖子", "favorite": "收藏" @@ -191,10 +199,31 @@ "today": "今日", "yesterday": "昨日", "threads": "帖子" - } + }, + "greeting": { + "morning": "早上好,${name}", + "afternoon": "下午好,${name}", + "evening": "晚上好,${name}", + "night": "夜深了,${name}" + }, + "todayPosts": "今天已有 ${count} 篇新帖", + "forumActivity": "论坛动态", + "guideTitle": "论坛导读", + "tools": "我的工具", + "supportNote": "完全自愿,不支持也能使用全部功能" + }, + "medalTitleHub": { + "title": "勋章与称号", + "entryDescription": "勋章中心、我的称号与称号商店", + "currentTitle": "当前称号", + "noTitle": "当前没有使用称号", + "loadFailed": "无法读取当前称号", + "medalCenterDescription": "浏览勋章与获取方式", + "myTitlesDescription": "使用、切换或摘下已拥有的称号", + "titleShopDescription": "浏览称号与价格,每次购买都会先确认" }, "topicPage": { - "title": "分区", + "title": "版块", "threads": "主题: ", "posts": "帖数: ", "today": "今日: ", @@ -254,6 +283,7 @@ "threadType": "白日梦" }, "showHelpTip": "查看帖子卡片内容的含义", + "preview": "预览", "attrs": { "help": "图标的含义", "closed": { @@ -309,6 +339,11 @@ "textScaleFactor": { "title": "文字缩放", "dialogTitle": "文字缩放系数" + }, + "threadContentScale": { + "title": "帖子内容缩放", + "detail": "在文字缩放之上,额外放大帖子页面中的楼层内容", + "dialogTitle": "帖子内容缩放系数" } }, "windowSection": { @@ -437,6 +472,7 @@ }, "exportData": "导出数据", "exportDataDetail": "默认不含任何账号的 Cookie 与密码;可选择用自设密码加密后一并导出", + "backupTitle": "备份与恢复", "exportBackup": { "title": "导出数据", "includeAccounts": "包含账号登录数据", @@ -533,7 +569,9 @@ "flutterVersion": "Flutter版本", "dartVersion": "Dart版本", "license": "版权", - "copyEnvironmentInfo": "复制环境信息" + "copyEnvironmentInfo": "复制环境信息", + "linksSection": "链接与支持", + "buildSection": "版本与构建" }, "loginPage": { "title": "登录天使动漫", @@ -590,6 +628,7 @@ "profilePage": { "title": "个人资料", "userNodeNotFound": "未找到用户信息节点", + "secondaryTitle": "称号", "checkin": { "title": "签到", "checkinFailed": "签到失败", @@ -795,6 +834,7 @@ "title": "同步所有账户", "detail": "为本机保存的每个账户逐一抓取提醒、私人消息和公共消息。未读红点仍只反映当前账户。与平时同步一样,列出后论坛侧的“新”标记会被消耗。", "running": "进行中", + "finished": "已完成", "empty": "本机没有保存任何账户", "user": { "waiting": "等待中", @@ -925,7 +965,12 @@ "tooltip": "今日红包", "claimed": "今日红包:获得 ${amount} ${unit}", "alreadyClaimed": "今天已经领过了,明天再来", - "failed": "领取失败:${err}" + "failed": "领取失败:${err}", + "claimedToday": "红包已领取", + "unavailable": "红包:暂无", + "unavailableHint": "首页目前没有提供今日红包(可能已领取或尚未开放),点一下重新检查", + "claimedRecordedHint": "本 App 记录到这个账号今天已领过红包;首页目前也没有提供红包", + "needLogin": "登录领红包" } }, "latestThreadPage": { @@ -1044,7 +1089,8 @@ "sizeValue": "${width: int} x ${height: int}", "cacheName": "缓存名称", "cacheSize": "缓存大小", - "usage": "用途" + "usage": "用途", + "fitToScreen": "适应屏幕" }, "forumCard": { "links": "链接", @@ -1696,7 +1742,9 @@ "unset": "摘下", "openTitleShop": "称号商店", "noneActivated": "当前尚未激活任何称号", - "activated": "使用中:${name: String}" + "activated": "使用中:${name: String}", + "empty": "目前还没有拥有任何称号", + "tapToUse": "点击称号即可使用" }, "titleShop": { "title": "称号商店", @@ -1764,7 +1812,14 @@ }, "customTitle": "自定义头衔", "signature": "个人签名", - "timezone": "时区" + "timezone": "时区", + "sections": { + "basic": "基本资料", + "contact": "联系方式", + "aboutMe": "关于我", + "forum": "论坛显示" + }, + "uploadHint": "修改只保留在本页,点右上角的上传按钮才会提交到论坛。" }, "urlInfoBottomSheet": { "title": "链接信息", @@ -1858,6 +1913,7 @@ "title": "屏蔽管理", "manageEntry": "屏蔽管理", "localHint": "已屏蔽用户(本机):只对这台设备上的当前账号生效,不会通知对方。对方的主题、回复与可识别来源的提醒会被隐藏;私人消息仍会照常接收,也可以手动打开阅读,但本机不再为此人的消息弹出提醒或显示未读角标。好友关系与论坛权限不变。无法判断作者的内容仍可能显示,例如主页导读、收藏与浏览记录里的主题标题、更新前收到的提醒,以及引用其他页楼层的内容。例:账号 A 屏蔽了 X,同一设备上的账号 B 不会继承;切回账号 A 时名单仍在。", + "localTitle": "已屏蔽用户(本机)", "block": "屏蔽此用户(本机)", "unblock": "解除屏蔽", "blockConfirmTitle": "屏蔽 ${name}?", diff --git a/lib/i18n/zh-TW.i18n.json b/lib/i18n/zh-TW.i18n.json index b8e9235e6..fb93ec0cb 100644 --- a/lib/i18n/zh-TW.i18n.json +++ b/lib/i18n/zh-TW.i18n.json @@ -162,8 +162,14 @@ }, "navigation": { "homepage": "首頁", - "topics": "分割區", + "topics": "版塊", + "activities": "活動", + "notice": "通知", "profile": "我的", + "moreSection": "更多功能", + "titleShop": "稱號商店", + "medalCenter": "勳章中心", + "bank": "銀行", "settings": "設定" }, "home": { @@ -176,6 +182,8 @@ "history": "歷史", "notice": "訊息", "checkin": "簽到", + "checkedIn": "已簽到", + "checkedInHint": "本 App 記錄到這個帳號今天已簽到", "myThread": "我的貼文", "latestThread": "最新貼文", "favorite": "收藏" @@ -191,10 +199,31 @@ "today": "今日", "yesterday": "昨日", "threads": "帖子" - } + }, + "greeting": { + "morning": "早安,${name}", + "afternoon": "午安,${name}", + "evening": "晚安,${name}", + "night": "夜深了,${name}" + }, + "todayPosts": "今天已有 ${count} 篇新帖", + "forumActivity": "論壇動態", + "guideTitle": "論壇導讀", + "tools": "我的工具", + "supportNote": "完全自願,不支持也能使用全部功能" + }, + "medalTitleHub": { + "title": "勳章與稱號", + "entryDescription": "勳章中心、我的頭銜與頭銜商店", + "currentTitle": "目前的頭銜", + "noTitle": "目前沒有使用頭銜", + "loadFailed": "無法讀取目前的頭銜", + "medalCenterDescription": "瀏覽勳章與取得方式", + "myTitlesDescription": "使用、切換或摘下已擁有的頭銜", + "titleShopDescription": "瀏覽頭銜與價格,每次購買都會先確認" }, "topicPage": { - "title": "分割區", + "title": "版塊", "threads": "主題: ", "posts": "帖數: ", "today": "今日: ", @@ -254,6 +283,7 @@ "threadType": "白日夢" }, "showHelpTip": "查看貼文卡片內容的意思", + "preview": "預覽", "attrs": { "help": "圖示的意義", "closed": { @@ -309,6 +339,11 @@ "textScaleFactor": { "title": "文字縮放", "dialogTitle": "文字縮放係數" + }, + "threadContentScale": { + "title": "貼文內容縮放", + "detail": "在文字縮放之上,額外放大貼文頁面中的樓層內容", + "dialogTitle": "貼文內容縮放係數" } }, "windowSection": { @@ -437,6 +472,7 @@ }, "exportData": "匯出資料", "exportDataDetail": "預設不含任何帳號的 Cookie 與密碼;可選擇用自設密碼加密後一併匯出", + "backupTitle": "備份與還原", "exportBackup": { "title": "匯出資料", "includeAccounts": "包含帳號登入資料", @@ -533,7 +569,9 @@ "flutterVersion": "Flutter版本", "dartVersion": "Dart版本", "license": "版權", - "copyEnvironmentInfo": "複製環境資訊" + "copyEnvironmentInfo": "複製環境資訊", + "linksSection": "連結與支持", + "buildSection": "版本與建置" }, "loginPage": { "title": "登入天使動漫", @@ -590,6 +628,7 @@ "profilePage": { "title": "個人資料", "userNodeNotFound": "未找到使用者資訊節點", + "secondaryTitle": "頭銜", "checkin": { "title": "簽到", "checkinFailed": "簽到失敗", @@ -795,6 +834,7 @@ "title": "同步所有帳號", "detail": "為本機保存的每個帳號逐一抓取提醒、私人訊息和公用訊息。未讀紅點仍只反映目前帳號。與平時同步一樣,列出後論壇端的「新」標記會被消耗。", "running": "進行中", + "finished": "已完成", "empty": "本機沒有保存任何帳號", "user": { "waiting": "等待中", @@ -925,7 +965,12 @@ "tooltip": "今日紅包", "claimed": "今日紅包:獲得 ${amount} ${unit}", "alreadyClaimed": "今天已經領過囉,明天再來", - "failed": "領取失敗:${err}" + "failed": "領取失敗:${err}", + "claimedToday": "紅包已領取", + "unavailable": "紅包:暫無", + "unavailableHint": "首頁目前沒有提供今日紅包(可能已領取或尚未開放),點一下重新檢查", + "claimedRecordedHint": "本 App 記錄到這個帳號今天已領過紅包;首頁目前也沒有提供紅包", + "needLogin": "登入領紅包" } }, "latestThreadPage": { @@ -1044,7 +1089,8 @@ "sizeValue": "${width: int} x ${height: int}", "cacheName": "快取名稱", "cacheSize": "快取大小", - "usage": "用途" + "usage": "用途", + "fitToScreen": "適應螢幕" }, "forumCard": { "links": "連結", @@ -1696,7 +1742,9 @@ "unset": "摘下", "openTitleShop": "頭銜商店", "noneActivated": "目前尚未啟動任何頭銜", - "activated": "使用中:${name: String}" + "activated": "使用中:${name: String}", + "empty": "目前還沒有擁有任何頭銜", + "tapToUse": "點一下頭銜即可使用" }, "titleShop": { "title": "頭銜商店", @@ -1764,7 +1812,14 @@ }, "customTitle": "自訂頭銜", "signature": "個人簽名", - "timezone": "時區" + "timezone": "時區", + "sections": { + "basic": "基本資料", + "contact": "聯絡方式", + "aboutMe": "關於我", + "forum": "論壇顯示" + }, + "uploadHint": "修改只保留在本頁,按右上角的上傳按鈕才會送到論壇。" }, "urlInfoBottomSheet": { "title": "連結訊息", @@ -1858,6 +1913,7 @@ "title": "屏蔽管理", "manageEntry": "屏蔽管理", "localHint": "已屏蔽用戶(本機):只對這台裝置上的目前帳號生效,不會通知對方。對方的主題、回覆與可辨識來源的提醒會被隱藏;私人訊息仍會照常接收,也可以手動開啟閱讀,但本機不再為此人的訊息跳出提醒或顯示未讀角標。好友關係與論壇權限不變。無法判斷作者的內容仍可能顯示,例如首頁導讀、收藏與瀏覽紀錄裡的主題標題、更新前收到的提醒,以及引用其他頁樓層的內容。例:帳號 A 屏蔽了 X,同一裝置上的帳號 B 不會繼承;切回帳號 A 時名單仍在。", + "localTitle": "已屏蔽用戶(本機)", "block": "屏蔽此用戶(本機)", "unblock": "解除屏蔽", "blockConfirmTitle": "屏蔽 ${name}?", diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 7495fef7b..db47d5926 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -21,6 +21,7 @@ import 'package:tsdm_client/features/homepage/view/homepage_page.dart'; import 'package:tsdm_client/features/image/view/image_detail_page.dart'; import 'package:tsdm_client/features/latest_thread/view/latest_thread_page.dart'; import 'package:tsdm_client/features/medal_center/view/medal_center_page.dart'; +import 'package:tsdm_client/features/medal_center/view/medal_title_hub_page.dart'; import 'package:tsdm_client/features/multi_user/view/manage_account_page.dart'; import 'package:tsdm_client/features/my_thread/view/my_thread_page.dart'; import 'package:tsdm_client/features/notification/models/models.dart'; @@ -297,6 +298,7 @@ final List _appRoutes = [ AppRoute(path: ScreenPaths.userBlock, builder: (_) => const UserBlockPage()), AppRoute(path: ScreenPaths.websiteBlocklist, builder: (_) => const WebsiteBlocklistPage()), AppRoute(path: ScreenPaths.medalCenter, builder: (_) => const MedalCenterPage()), + AppRoute(path: ScreenPaths.medalTitleHub, builder: (_) => const MedalTitleHubPage()), AppRoute(path: ScreenPaths.bank, builder: (_) => const BankPage()), AppRoute(path: ScreenPaths.editUserProfile, builder: (_) => const EditUserProfilePage()), AppRoute( diff --git a/lib/routes/screen_paths.dart b/lib/routes/screen_paths.dart index 541528c40..f231f41f1 100644 --- a/lib/routes/screen_paths.dart +++ b/lib/routes/screen_paths.dart @@ -86,6 +86,9 @@ class ScreenPaths { /// Read-only medal catalogue. static const String medalCenter = '/medalCenter'; + /// Common entry of the medal centre, the current account's titles and the title shop. + static const String medalTitleHub = '/medalTitleHub'; + /// Community bank balances, records and current-account transactions. static const String bank = '/bank'; @@ -345,6 +348,9 @@ class DialogPaths { /// Dialog to let user picker a text scale factor. static const String textScalePicker = '/dialog/textScalePicker'; + /// Dialog to let user pick the extra text scale of thread content. + static const String threadContentScalePicker = '/dialog/threadContentScalePicker'; + /// Dialog to let user select the duration between auto syncing notice events. static const String selectAutoSyncDuration = '/dialog/selectAutoSyncDuration'; diff --git a/lib/shared/models/post.dart b/lib/shared/models/post.dart index fdcfe094d..f0ea5e600 100644 --- a/lib/shared/models/post.dart +++ b/lib/shared/models/post.dart @@ -396,9 +396,11 @@ class Post with PostMappable { final badge = element.querySelector('div#$avatarId div.tsdm_norm_title > img')?._lazyImageUrl(); // We can not use `:is(.tsdmtitles, .tsdm_lv_title)` here. // - // Discuz X5: `
`. + // Discuz X5: `
`. The image is looked + // up anywhere inside that same title block, so a link around it (``) still counts; nothing outside + // the block of this floor's author column is used. final secondBadge = - userProfileNode?.querySelector('div.tsdmtitle-badges div.tsdmtitle-title > img')?._lazyImageUrl() ?? + userProfileNode?.querySelector('div.tsdmtitle-badges div.tsdmtitle-title img')?._lazyImageUrl() ?? element.querySelector('div.tsdm_statbar > a > img.tsdmtitles')?._lazyImageUrl() ?? element.querySelector('div.tsdm_statbar > a > img.tsdm_lv_title')?._lazyImageUrl(); final signature = element.querySelector('div.sign_inner')?.innerHtml; diff --git a/lib/shared/models/settings.dart b/lib/shared/models/settings.dart index 186c85794..7c358b16d 100644 --- a/lib/shared/models/settings.dart +++ b/lib/shared/models/settings.dart @@ -216,6 +216,16 @@ enum SettingsKeys implements Comparable> { type: double, defaultValue: 1, ), + + /// Extra text scale applied on top of [textScaleFactor] to the floors in thread page only. + /// + /// Users reading long posts on a large screen asked for post content larger than the global scale allows + /// (GitHub #137). Ranges from 1.0 (no extra scale) to 2.0 in steps of 0.1. + threadContentScale( + name: 'threadContentScale', + type: double, + defaultValue: 1, + ), ; const SettingsKeys({required this.name, required this.type, required this.defaultValue}); diff --git a/lib/shared/models/settings_map.dart b/lib/shared/models/settings_map.dart index 5e6f852fb..40d58af60 100644 --- a/lib/shared/models/settings_map.dart +++ b/lib/shared/models/settings_map.dart @@ -52,6 +52,7 @@ class SettingsMap with SettingsMapMappable { required this.collapseAppBarWhenScroll, required this.threadFloorInteractionMode, required this.textScaleFactor, + required this.threadContentScale, this.windowMaximized = false, this.enableBackgroundMessageService = false, this.autoDailyRedPacket = false, @@ -106,6 +107,7 @@ class SettingsMap with SettingsMapMappable { final bool collapseAppBarWhenScroll; final ThreadFloorInteractionMode threadFloorInteractionMode; final double textScaleFactor; + final double threadContentScale; SettingsMap copyWithKey(SettingsKeys key, T? value) { assert( @@ -164,6 +166,7 @@ class SettingsMap with SettingsMapMappable { threadFloorInteractionMode: value as ThreadFloorInteractionMode?, ), SettingsKeys.textScaleFactor => copyWith(textScaleFactor: value as double?), + SettingsKeys.threadContentScale => copyWith(threadContentScale: value as double?), }; } } diff --git a/lib/shared/providers/storage_provider/storage_provider.dart b/lib/shared/providers/storage_provider/storage_provider.dart index 9f98d70bb..79ec8378a 100644 --- a/lib/shared/providers/storage_provider/storage_provider.dart +++ b/lib/shared/providers/storage_provider/storage_provider.dart @@ -623,6 +623,10 @@ class StorageProvider with LoggerMixin { return; }); + /// Time of the last check-in this app made for the account [uid], null when none is recorded or the account is not + /// stored. + Future getLastCheckinTime(int uid) async => (await CookieDao(_db).selectCookieByUid(uid))?.lastCheckin; + /// Update the last checkin success datetime in storage for user [uid]. VoidTask updateLastCheckinTime(int uid, DateTime datetime) => VoidTask(() async { await CookieDao(_db).updateLastCheckinTime(uid, datetime); diff --git a/lib/themes/app_themes.dart b/lib/themes/app_themes.dart index a70c9c958..88ead4a29 100644 --- a/lib/themes/app_themes.dart +++ b/lib/themes/app_themes.dart @@ -1,5 +1,6 @@ import 'package:flex_color_scheme/flex_color_scheme.dart'; import 'package:flutter/material.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// App themes. class AppTheme { @@ -33,7 +34,34 @@ class AppTheme { drawerRadius: 0, ); - static CardThemeData _buildCardTheme() => const CardThemeData(elevation: 0); + /// Cards are the rounded content surfaces of the homepage design, app wide. + static CardThemeData _buildCardTheme(ThemeData base, ColorScheme colorScheme) => + base.cardTheme.copyWith(elevation: 0, shape: appSurfaceShapeOf(colorScheme)); + + /// Popup menus and dialogs follow the rounded surfaces. + static PopupMenuThemeData _buildPopupMenuTheme(ThemeData base) => base.popupMenuTheme.copyWith( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ); + + static DialogThemeData _buildDialogTheme(ThemeData base) => base.dialogTheme.copyWith( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appSurfaceRadius + 6)), + ); + + /// Theme parts that depend on the final color scheme. + static ThemeData _finish(ThemeData base, ColorScheme? seedScheme) { + final colorScheme = seedScheme ?? base.colorScheme; + return base.copyWith( + colorScheme: seedScheme, + cardTheme: _buildCardTheme(base, colorScheme), + chipTheme: _buildChipTheme(), + listTileTheme: _buildListTileTheme(), + navigationDrawerTheme: _buildNavigationDrawerTheme(seedScheme), + tabBarTheme: _buildTabBarTheme(), + progressIndicatorTheme: _buildProcessIndicatorTheme(), + popupMenuTheme: _buildPopupMenuTheme(base), + dialogTheme: _buildDialogTheme(base), + ); + } static ChipThemeData _buildChipTheme() => const ChipThemeData(padding: EdgeInsets.all(2)); @@ -75,7 +103,7 @@ class AppTheme { if (seedColor != null) { seedScheme = ColorScheme.fromSeed(seedColor: seedColor); } - return FlexThemeData.light( + final base = FlexThemeData.light( fontFamily: fontFamily.isEmpty ? null : fontFamily, colors: seedScheme != null ? FlexSchemeColor( @@ -95,15 +123,8 @@ class AppTheme { subThemesData: _subThemesData, keyColors: const FlexKeyColors(), visualDensity: FlexColorScheme.comfortablePlatformDensity, - ).copyWith( - colorScheme: seedScheme, - cardTheme: _buildCardTheme(), - chipTheme: _buildChipTheme(), - listTileTheme: _buildListTileTheme(), - navigationDrawerTheme: _buildNavigationDrawerTheme(seedScheme), - tabBarTheme: _buildTabBarTheme(), - progressIndicatorTheme: _buildProcessIndicatorTheme(), ); + return _finish(base, seedScheme); } /// App dark themes. @@ -112,7 +133,7 @@ class AppTheme { if (seedColor != null) { seedScheme = ColorScheme.fromSeed(seedColor: seedColor, brightness: Brightness.dark); } - return FlexThemeData.dark( + final base = FlexThemeData.dark( fontFamily: fontFamily.isEmpty ? null : fontFamily, colors: seedScheme != null ? FlexSchemeColor( @@ -132,15 +153,8 @@ class AppTheme { subThemesData: _subThemesData, keyColors: const FlexKeyColors(), visualDensity: FlexColorScheme.comfortablePlatformDensity, - ).copyWith( - colorScheme: seedScheme, - cardTheme: _buildCardTheme(), - chipTheme: _buildChipTheme(), - listTileTheme: _buildListTileTheme(), - navigationDrawerTheme: _buildNavigationDrawerTheme(seedScheme), - tabBarTheme: _buildTabBarTheme(), - progressIndicatorTheme: _buildProcessIndicatorTheme(), ); + return _finish(base, seedScheme); } } diff --git a/lib/utils/show_bottom_sheet.dart b/lib/utils/show_bottom_sheet.dart index aec72e94d..f2af9302b 100644 --- a/lib/utils/show_bottom_sheet.dart +++ b/lib/utils/show_bottom_sheet.dart @@ -22,6 +22,7 @@ import 'package:tsdm_client/shared/providers/image_cache_provider/models/models. import 'package:tsdm_client/utils/clipboard.dart'; import 'package:tsdm_client/utils/platform.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/network_indicator_image.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -79,23 +80,49 @@ Future showCustomBottomSheet({ // preferredSize: const Size.fromHeight(kToolbarHeight), child: Stack( children: [ - Align( - alignment: .topCenter, - child: Padding( - padding: const EdgeInsets.only(top: 20), - child: Text(title, style: Theme.of(context).textTheme.titleLarge), + // Drag handle: the sheet can be swiped down to close. + Positioned( + top: 6, + left: 0, + right: 0, + child: Center( + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + child: const SizedBox(width: 32, height: 4), + ), ), ), - if (topBar != null) Positioned(top: kToolbarHeight, child: topBar), + // Title and close button share one row: a long title (or a large text scale) is cut with an + // ellipsis instead of running under the button. Positioned( top: 12, - right: 24, - child: IconButton( - icon: const Icon(Icons.close_outlined), - tooltip: context.t.general.close, - onPressed: () async => context.pop(), + left: 16, + right: 16, + height: kToolbarHeight - 12, + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + icon: const Icon(Icons.close_outlined), + tooltip: context.t.general.close, + onPressed: () async => context.pop(), + ), + ], ), ), + if (topBar != null) Positioned(top: kToolbarHeight, child: topBar), ], ), ), @@ -122,10 +149,13 @@ Future showCustomBottomSheet({ /// * Copy image url. /// * Jump to related href url (optional). /// * Reload image. +/// +/// [showCheckDetail] is false when the sheet is opened from the full page viewer itself. Future showImageActionBottomSheet({ required BuildContext context, required String imageUrl, String? hrefUrl, + bool showCheckDetail = true, }) async { final tr = context.t.imageBottomSheet; @@ -133,27 +163,32 @@ Future showImageActionBottomSheet({ context: context, title: tr.title, childrenBuilder: (context) => [ - Align( - child: ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 100), - child: Padding(padding: edgeInsetsT4B4, child: NetworkIndicatorImage(imageUrl)), + Padding( + padding: edgeInsetsL16R16, + child: AppInsetBlock( + outlined: true, + padding: edgeInsetsT4B4, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 100), + child: NetworkIndicatorImage(imageUrl), + ), ), ), ), sizedBoxW12H12, - ListTile( - leading: const Icon(Icons.fullscreen_outlined), - title: Text(tr.checkDetail), - onTap: () async { - await context.pushNamed(ScreenPaths.imageDetail, pathParameters: {'imageUrl': imageUrl}); - if (context.mounted) { - context.pop(); - } - }, - ), + if (showCheckDetail) + ListTile( + leading: const Icon(Icons.fullscreen_outlined), + title: Text(tr.checkDetail), + onTap: () async { + await context.pushNamed(ScreenPaths.imageDetail, pathParameters: {'imageUrl': imageUrl}); + if (context.mounted) { + context.pop(); + } + }, + ), ListTile( leading: const Icon(Icons.copy_outlined), title: Text(tr.copyImageUrl), @@ -265,25 +300,27 @@ Future showUrlInfoBottomSheet({ title: tr.title, childrenBuilder: (context) { return [ - Container( - width: double.infinity, - padding: edgeInsetsL12T12R12B12.add(edgeInsetsL8R8), - color: theme.colorScheme.surfaceContainerHighest.withAlpha(160), - child: Column( - crossAxisAlignment: .start, - spacing: 8, - children: [ - SingleLineText(url, style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.primary)), - Container( - padding: edgeInsetsL8R8.add(edgeInsetsL4R4), - decoration: BoxDecoration( - color: theme.colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), + Padding( + padding: edgeInsetsL16R16, + child: AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: .start, + spacing: 8, + children: [ + SingleLineText(url, style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.primary)), + Container( + padding: edgeInsetsL8R8.add(edgeInsetsL4R4), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Text(parseResultTitle, style: theme.textTheme.labelSmall), ), - child: Text(parseResultTitle, style: theme.textTheme.labelSmall), - ), - Text(parseResultDetail, style: theme.textTheme.bodySmall), - ], + Text(parseResultDetail, style: theme.textTheme.bodySmall), + ], + ), ), ), sizedBoxW12H12, @@ -335,11 +372,24 @@ Future showEmailBottomSheet({required BuildContext context, required Strin title: tr.title, childrenBuilder: (context) { return [ - Container( - width: double.infinity, - padding: edgeInsetsL12T12R12B12.add(edgeInsetsL8R8), - color: theme.colorScheme.surfaceContainerHighest.withAlpha(160), - child: SingleLineText(address, style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.primary)), + Padding( + padding: edgeInsetsL16R16, + child: AppInsetBlock( + outlined: true, + padding: edgeInsetsL12T12R12B12, + child: Row( + children: [ + Icon(Icons.alternate_email_outlined, size: 20, color: theme.colorScheme.primary), + sizedBoxW8H8, + Expanded( + child: SingleLineText( + address, + style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.primary), + ), + ), + ], + ), + ), ), sizedBoxW12H12, ListTile( diff --git a/lib/utils/show_dialog.dart b/lib/utils/show_dialog.dart index cac682a21..1ffe934d3 100644 --- a/lib/utils/show_dialog.dart +++ b/lib/utils/show_dialog.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; /// Show a dialog with given [title] and [message], with a ok button to navigate @@ -19,7 +20,7 @@ Future showMessageSingleButtonDialog({ return RootPage( DialogPaths.messageSingleButton, CustomAlertDialog.sync( - title: Text(title), + title: AppDialogTitle(icon: Icons.info_outline, title: title), content: SelectableText(message), actions: [ TextButton( @@ -57,7 +58,12 @@ Future showQuestionDialog({ return RootPage( DialogPaths.question, CustomAlertDialog.sync( - title: Text(title), + // A destructive question gets the error colors in its title; the buttons and their order are unchanged. + title: AppDialogTitle( + icon: dangerous ? Icons.warning_amber_outlined : Icons.help_outline, + title: title, + error: dangerous, + ), content: message != null ? SelectableText(message) : Text.rich(richMessage!), actions: [ TextButton( diff --git a/lib/widgets/app_surface.dart b/lib/widgets/app_surface.dart new file mode 100644 index 000000000..e8965513c --- /dev/null +++ b/lib/widgets/app_surface.dart @@ -0,0 +1,927 @@ +import 'package:flutter/material.dart'; +import 'package:tsdm_client/constants/layout.dart'; + +/// Corner radius of the content surfaces of the app, the homepage cards included. +const appSurfaceRadius = 18.0; + +/// Corner radius of the small inner blocks of a surface (tool buttons, tags, quoted blocks). +const appInnerRadius = 12.0; + +/// Widest a list of threads, forums or other rows grows; wider windows center it. +const appListMaxWidth = 960.0; + +/// Widest the floors of a thread grow, so lines stay readable on desktop windows. +const appReadingMaxWidth = 920.0; + +/// Widest a form or a detail page (profile, settings like pages) grows. +const appFormMaxWidth = 760.0; + +/// Gap between two surfaces of a list on phones. +const appSurfaceGapCompact = 8.0; + +/// Gap between two surfaces of a list on wider windows. +const appSurfaceGap = 12.0; + +/// Window width from which pages may use a second column. +const appTwoColumnWidth = 840.0; + +/// Shape shared by the content surfaces: rounded, with a hairline border in the theme's outline variant. +ShapeBorder appSurfaceShape(BuildContext context) => appSurfaceShapeOf(Theme.of(context).colorScheme); + +/// [appSurfaceShape] for a [colorScheme], used to build the app theme. +RoundedRectangleBorder appSurfaceShapeOf(ColorScheme colorScheme) => RoundedRectangleBorder( + borderRadius: BorderRadius.circular(appSurfaceRadius), + side: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6)), +); + +/// Horizontal padding of page content for the available [width]: tighter on phones. +double appPagePadding(double width) => width < 600 ? 12 : 16; + +/// A rounded content surface: the card of the homepage, reusable by every page. +/// +/// Tappable when [onTap] or [onLongPress] is set; [padding] is applied inside the ink area. +class AppSurface extends StatelessWidget { + /// Constructor. + const AppSurface({ + required this.child, + this.padding = edgeInsetsL16T12R16B12, + this.onTap, + this.onLongPress, + this.color, + this.margin = EdgeInsets.zero, + super.key, + }); + + /// Content. + final Widget child; + + /// Padding around [child]. + final EdgeInsetsGeometry padding; + + /// Tap callback. + final VoidCallback? onTap; + + /// Long press callback. + final VoidCallback? onLongPress; + + /// Background, the card color of the theme when null. + final Color? color; + + /// Outer margin. + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context) { + final content = Padding(padding: padding, child: child); + return Card( + margin: margin, + clipBehavior: Clip.antiAlias, + shape: appSurfaceShape(context), + color: color, + child: onTap == null && onLongPress == null + ? content + : InkWell(onTap: onTap, onLongPress: onLongPress, child: content), + ); + } +} + +/// Title of a section inside a page or a surface: bold title, optional leading icon and trailing action. +class AppSectionHeader extends StatelessWidget { + /// Constructor. + const AppSectionHeader( + this.title, { + this.icon, + this.trailing, + this.padding = const EdgeInsets.symmetric(vertical: 8), + super.key, + }); + + /// Section title. + final String title; + + /// Optional leading icon. + final IconData? icon; + + /// Optional action at the end, usually a text button. + final Widget? trailing; + + /// Padding around the header. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: padding, + child: Row( + children: [ + if (icon != null) ...[Icon(icon, size: 20, color: colorScheme.primary), sizedBoxW8H8], + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ?trailing, + ], + ), + ); + } +} + +/// Keeps [child] at most [maxWidth] wide and centered horizontally, for lists and pages on wide windows. +/// +/// Phones are not affected: the constraint only matters once the window is wider than [maxWidth]. +class AppContentWidth extends StatelessWidget { + /// Constructor. + const AppContentWidth({required this.child, this.maxWidth = appListMaxWidth, super.key}); + + /// Content. + final Widget child; + + /// Widest [child] grows. + final double maxWidth; + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: child, + ), + ); +} + +/// Horizontal padding that centers content of at most [maxWidth] in a window of [width], at least [minPadding]. +/// +/// Use it as the padding of a scroll view so the scrollbar and the pull to refresh area keep the full width while the +/// rows stay readable. +EdgeInsets appCenteredPadding(double width, {double maxWidth = appListMaxWidth, double? minPadding}) { + final min = minPadding ?? appPagePadding(width); + final side = (width - maxWidth) / 2; + final horizontal = side > min ? side : min; + return EdgeInsets.symmetric(horizontal: horizontal); +} + +/// Builds a scroll view whose rows are centered and at most [maxWidth] wide in the room the page really has. +/// +/// [builder] gets the horizontal padding to use for the scroll view (see [appCenteredPadding]) and the full width of +/// the page (to choose a number of columns, see [appColumnsFor]); the scroll view +/// itself keeps the full width so scrolling, the scrollbar and pull to refresh work anywhere in the page. The room is +/// measured, not taken from the window, so a navigation rail or drawer beside the page is accounted for. +class AppCenteredList extends StatelessWidget { + /// Constructor. + const AppCenteredList({required this.builder, this.maxWidth = appListMaxWidth, super.key}); + + /// Builds the scroll view with the given horizontal padding, knowing the full width of the page. + final Widget Function(BuildContext context, EdgeInsets horizontalPadding, double width) builder; + + /// Widest the rows grow. + final double maxWidth; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => + builder(context, appCenteredPadding(constraints.maxWidth, maxWidth: maxWidth), constraints.maxWidth), + ); +} + +/// Separator between the surfaces of a list. +const appListSeparator = SizedBox(height: appSurfaceGapCompact); + +/// Number of card columns that fit in [width]: one on phones, two from [appTwoColumnWidth]. +int appColumnsFor(double width) => width >= appTwoColumnWidth ? 2 : 1; + +/// Number of rows needed for [count] cards in [columns] columns. +int appRowCount(int count, int columns) => (count + columns - 1) ~/ columns; + +/// One row of a card grid: the cards [row] * [columns] ... of the [count] cards built by [itemBuilder], top aligned. +/// +/// Cards keep their own height; the last row is padded with empty room so every column keeps the same width. +class AppColumnsRow extends StatelessWidget { + /// Constructor. + const AppColumnsRow({ + required this.row, + required this.columns, + required this.count, + required this.itemBuilder, + this.gap = appSurfaceGapCompact, + super.key, + }); + + /// Row index. + final int row; + + /// Number of columns. + final int columns; + + /// Total number of cards. + final int count; + + /// Builds the card at an index. + final IndexedWidgetBuilder itemBuilder; + + /// Gap between the columns. + final double gap; + + @override + Widget build(BuildContext context) { + if (columns <= 1) { + return itemBuilder(context, row); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var column = 0; column < columns; column++) ...[ + if (column > 0) SizedBox(width: gap), + Expanded( + child: row * columns + column < count ? itemBuilder(context, row * columns + column) : sizedBoxEmpty, + ), + ], + ], + ); + } +} + +/// Empty, failure or informational state of a page or a section: icon, message, optional action. +/// +/// Uses only data the caller provides; the message is expected to be a translated text. +class AppStateView extends StatelessWidget { + /// Constructor. + const AppStateView({ + required this.message, + this.icon = Icons.inbox_outlined, + this.action, + this.error = false, + this.scrollable = true, + super.key, + }); + + /// Message to show. + final String message; + + /// Leading illustration icon. + final IconData icon; + + /// Optional action, usually a retry button. + final Widget? action; + + /// Use the error colors. + final bool error; + + /// Scroll by itself when the room is too small. + /// + /// Set to false when the view is already inside a scroll view (e.g. to keep pull to refresh on an empty list). + final bool scrollable; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final content = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: error ? colorScheme.errorContainer : colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appSurfaceRadius), + ), + child: SizedBox( + width: 64, + height: 64, + child: Icon(icon, size: 32, color: error ? colorScheme.onErrorContainer : colorScheme.outline), + ), + ), + sizedBoxW16H16, + Text( + message, + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (action != null) ...[sizedBoxW16H16, action!], + ], + ), + ); + if (!scrollable) { + return Center( + child: Padding(padding: edgeInsetsL24T24R24B24, child: content), + ); + } + return Center( + child: SingleChildScrollView(padding: edgeInsetsL24T24R24B24, child: content), + ); + } +} + +/// [AppStateView] filling the viewport of a scroll view with the given [physics], so an empty list keeps its pull to +/// refresh. +class AppScrollableStateView extends StatelessWidget { + /// Constructor. + const AppScrollableStateView({required this.physics, required this.child, super.key}); + + /// Physics of the scroll view, usually given by the refresh wrapper. + final ScrollPhysics? physics; + + /// State view, built with `scrollable: false`. + final AppStateView child; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + physics: physics, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth, minHeight: constraints.maxHeight), + child: child, + ), + ), + ); +} + +/// Rounded square holding an icon: the leading block of a row in a surface (notices, sections, summaries). +class AppIconTile extends StatelessWidget { + /// Constructor. + const AppIconTile(this.icon, {this.size = 40, this.color, this.foregroundColor, super.key}); + + /// Icon to show. + final IconData icon; + + /// Width and height of the block. + final double size; + + /// Background, the secondary container of the theme when null. + final Color? color; + + /// Icon color, the on secondary container color of the theme when null. + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: color ?? colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: SizedBox( + width: size, + height: size, + child: Icon(icon, size: size / 2, color: foregroundColor ?? colorScheme.onSecondaryContainer), + ), + ); + } +} + +/// Small rounded piece of meta information (author, forum, time, counter) under the title of a card. +/// +/// Wraps to several lines instead of overflowing when the text is long or the font is large. +class AppInfoPill extends StatelessWidget { + /// Constructor. + const AppInfoPill({required this.icon, required this.label, this.tooltip, this.large = false, super.key}); + + /// Leading icon. + final IconData icon; + + /// Text. + final String label; + + /// Optional tooltip and semantic label explaining the value, for numbers without a caption. + final String? tooltip; + + /// Bigger icon, text and padding, for cards shown large on wide windows. + final bool large; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final pill = DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Padding( + padding: large + ? const EdgeInsets.symmetric(horizontal: 10, vertical: 5) + : const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: large ? 17 : 14, color: colorScheme.outline), + SizedBox(width: large ? 6 : 4), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: (large ? textTheme.labelLarge : textTheme.labelSmall)?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + if (tooltip == null) { + return pill; + } + return Tooltip(message: tooltip, child: pill); + } +} + +/// A group of form fields or settings inside a surface, with an optional section title. +/// +/// The children are stacked with [gap] between them; each keeps its own width, text fields fill the surface. +class AppFormSection extends StatelessWidget { + /// Constructor. + const AppFormSection({ + required this.children, + this.title, + this.icon, + this.trailing, + this.gap = 12, + this.padding = edgeInsetsL16T12R16B12, + super.key, + }); + + /// Section title, no header when null. + final String? title; + + /// Optional icon of the title. + final IconData? icon; + + /// Optional action at the end of the title. + final Widget? trailing; + + /// Fields of the section. + final List children; + + /// Room between two children. + final double gap; + + /// Padding inside the surface. + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) => AppSurface( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) + AppSectionHeader(title!, icon: icon, trailing: trailing, padding: const EdgeInsets.only(bottom: 8)), + for (var i = 0; i < children.length; i++) ...[if (i > 0) SizedBox(height: gap), children[i]], + ], + ), + ); +} + +/// Rounded block inside a surface: quoted text, the frame of an editor, the body of an embedded card. +class AppInsetBlock extends StatelessWidget { + /// Constructor. + const AppInsetBlock({ + required this.child, + this.padding = edgeInsetsL12T8R12B8, + this.color, + this.outlined = false, + super.key, + }); + + /// Content. + final Widget child; + + /// Padding around [child]. + final EdgeInsetsGeometry padding; + + /// Background, the low surface container of the theme when null. + final Color? color; + + /// Draw the hairline border of the surfaces. + final bool outlined; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: color ?? colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(appInnerRadius), + border: outlined ? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.6)) : null, + ), + child: Padding(padding: padding, child: child), + ); + } +} + +/// Tone of an [AppNoticeBanner]. +enum AppNoticeTone { + /// Neutral information. + info, + + /// Something limits what the user can do (closed thread, blocked content). + warning, + + /// Something failed or was refused. + error, +} + +/// One line (or a few) notice inside a page or a surface: icon, message, optional actions. +/// +/// Used for the soft closed hint of a thread, a blocked floor, refused forms. The colors follow [tone]. +class AppNoticeBanner extends StatelessWidget { + /// Constructor. + const AppNoticeBanner({ + required this.message, + this.title, + this.icon, + this.tone = AppNoticeTone.info, + this.actions = const [], + this.selectable = false, + super.key, + }); + + /// Optional bold first line. + final String? title; + + /// Message. + final String message; + + /// Leading icon, chosen from [tone] when null. + final IconData? icon; + + /// Colors of the banner. + final AppNoticeTone tone; + + /// Buttons under the message. + final List actions; + + /// Let the user select and copy the message (forum messages). + final bool selectable; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final (Color background, Color foreground, IconData defaultIcon) = switch (tone) { + AppNoticeTone.info => (colorScheme.secondaryContainer, colorScheme.onSecondaryContainer, Icons.info_outline), + AppNoticeTone.warning => ( + colorScheme.tertiaryContainer, + colorScheme.onTertiaryContainer, + Icons.lock_clock_outlined, + ), + AppNoticeTone.error => (colorScheme.errorContainer, colorScheme.onErrorContainer, Icons.error_outline), + }; + final messageStyle = textTheme.bodyMedium?.copyWith(color: foreground); + return AppInsetBlock( + color: background, + padding: edgeInsetsL12T12R12B12, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon ?? defaultIcon, size: 20, color: foreground), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title!, + style: textTheme.titleSmall?.copyWith(color: foreground, fontWeight: FontWeight.bold), + ), + sizedBoxW4H4, + ], + if (selectable) SelectableText(message, style: messageStyle) else Text(message, style: messageStyle), + if (actions.isNotEmpty) ...[ + sizedBoxW8H8, + Wrap(spacing: 8, runSpacing: 4, children: actions), + ], + ], + ), + ), + ], + ), + ); + } +} + +/// Background of an [AppEmbedCard] for the nesting [elevation] the HTML renderer gives its embedded cards. +/// +/// Top level blocks (elevation 0 or null) use the low surface container, nested ones a higher container, so a block +/// inside another one stays distinct without shadows. +Color appEmbedColor(BuildContext context, double? elevation) { + final colorScheme = Theme.of(context).colorScheme; + return (elevation ?? 0) > 0.1 ? colorScheme.surfaceContainerHigh : colorScheme.surfaceContainerLow; +} + +/// Frame of a special block embedded in a floor (locked content, poll, red packet, rate, bounty, code…). +/// +/// A tinted rounded block with a header row (icon tile, bold title, optional subtitle and trailing widget) and the +/// [child] under it. Keeps the reading width of the floor; nothing is clipped, long titles wrap. +class AppEmbedCard extends StatelessWidget { + /// Constructor. + const AppEmbedCard({ + required this.icon, + required this.title, + this.subtitle, + this.trailing, + this.child, + this.accent, + this.color, + this.margin = const EdgeInsets.symmetric(vertical: 4), + super.key, + }); + + /// Background of the block, the low surface container of the theme when null. + /// + /// Blocks nested in another block (a locked area inside a spoiler) pass a higher container to stand out. + final Color? color; + + /// Icon of the header. + final IconData icon; + + /// Title of the header. + final String title; + + /// Optional second line of the header. + final String? subtitle; + + /// Optional widget at the end of the header. + final Widget? trailing; + + /// Body. + final Widget? child; + + /// Background of the icon tile, the secondary container of the theme when null. + final Color? accent; + + /// Outer margin. + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: margin, + child: AppInsetBlock( + outlined: true, + color: color, + padding: edgeInsetsL12T12R12B12, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + AppIconTile(icon, size: 32, color: accent), + sizedBoxW8H8, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + if (subtitle != null) + Text(subtitle!, style: textTheme.labelSmall?.copyWith(color: colorScheme.outline)), + ], + ), + ), + ?trailing, + ], + ), + if (child != null) ...[sizedBoxW8H8, child!], + ], + ), + ), + ); + } +} + +/// Bar pinned under a form: hairline on top, content centered at most [maxWidth] wide, above the system insets. +class AppBottomActionBar extends StatelessWidget { + /// Constructor. + const AppBottomActionBar({required this.child, this.maxWidth = appFormMaxWidth, super.key}); + + /// Usually the submit button, maybe with a status line above it. + final Widget child; + + /// Widest [child] grows. + final double maxWidth; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + border: Border(top: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6))), + ), + child: SafeArea( + top: false, + child: AppContentWidth( + maxWidth: maxWidth, + child: Padding(padding: edgeInsetsL16T12R16B12, child: child), + ), + ), + ); + } +} + +/// Title of a dialog: icon tile and a title that wraps instead of being cut. +/// +/// Built from a [Row] with an [Expanded] text only, no [LayoutBuilder], so it is safe in the intrinsic layout of an +/// [AlertDialog]. [error] uses the error container colors (destructive confirmations). +class AppDialogTitle extends StatelessWidget { + /// Constructor. + const AppDialogTitle({required this.icon, required this.title, this.error = false, super.key}); + + /// Icon of the tile. + final IconData icon; + + /// Title text. + final String title; + + /// Use the error colors. + final bool error; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Row( + children: [ + AppIconTile( + icon, + size: 36, + color: error ? colorScheme.errorContainer : null, + foregroundColor: error ? colorScheme.onErrorContainer : null, + ), + sizedBoxW12H12, + Expanded(child: Text(title)), + ], + ); + } +} + +/// A group of setting like rows (list tiles, switches) in one surface: optional section header, rows separated by +/// hairlines, optional footer (a notice) under them. +/// +/// The rows keep their own ink; the surface clips it to the rounded corners. Use one group per section of a settings +/// or a details page instead of loose tiles on the page ground. +class AppTileGroup extends StatelessWidget { + /// Constructor. + const AppTileGroup({required this.children, this.title, this.icon, this.trailing, this.footer, super.key}); + + /// Section title, no header when null. + final String? title; + + /// Icon of the header. + final IconData? icon; + + /// Optional action at the end of the header. + final Widget? trailing; + + /// Rows of the group. + final List children; + + /// Optional block under the rows, usually an [AppNoticeBanner]. + final Widget? footer; + + @override + Widget build(BuildContext context) { + final divider = Divider( + height: 1, + thickness: 1, + indent: 16, + endIndent: 16, + color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.4), + ); + return AppSurface( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) + AppSectionHeader( + title!, + icon: icon, + trailing: trailing, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + ), + for (var i = 0; i < children.length; i++) ...[if (i > 0) divider, children[i]], + if (footer != null) Padding(padding: edgeInsetsL12T4R12B12, child: footer), + if (footer == null && title != null) const SizedBox(height: 4), + ], + ), + ); + } +} + +/// Filled, rounded decoration of the form fields of pages and dialogs; helper and error texts may wrap. +InputDecoration appFieldDecoration({String? label, String? hint, String? helper, IconData? icon, Widget? suffix}) => + InputDecoration( + labelText: label, + hintText: hint, + helperText: helper, + helperMaxLines: 3, + errorMaxLines: 3, + prefixIcon: icon == null ? null : Icon(icon), + suffixIcon: suffix, + filled: true, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ); + +/// Borderless filled decoration of the pickers (dropdowns) on page surfaces. +/// +/// The floating label stays inside the filled area (Material filled style) instead of straddling an invisible outline +/// at the top edge; focus draws the usual bottom indicator. Every border state is set so the theme's outline borders +/// do not come back. +InputDecoration appPickerDecoration(BuildContext context, {required String label, IconData? icon}) { + final radius = BorderRadius.circular(appInnerRadius); + final none = UnderlineInputBorder(borderRadius: radius, borderSide: BorderSide.none); + return InputDecoration( + labelText: label, + prefixIcon: icon == null ? null : Icon(icon), + filled: true, + contentPadding: const EdgeInsetsDirectional.fromSTEB(12, 10, 12, 10), + border: none, + enabledBorder: none, + disabledBorder: none, + focusedBorder: UnderlineInputBorder( + borderRadius: radius, + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2), + ), + ); +} + +/// Page switcher of a paged result: previous, "current / total" (opens [onJump]) and next. +/// +/// A null callback disables its button. Tooltips come from [MaterialLocalizations], no extra translation is needed. +class AppPager extends StatelessWidget { + /// Constructor. + const AppPager({ + required this.current, + required this.total, + this.onPrevious, + this.onNext, + this.onJump, + super.key, + }); + + /// Current page, null when unknown. + final int? current; + + /// Total pages, null when unknown. + final int? total; + + /// Go to the previous page. + final VoidCallback? onPrevious; + + /// Go to the next page. + final VoidCallback? onNext; + + /// Pick a page. + final VoidCallback? onJump; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final localizations = MaterialLocalizations.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + tooltip: localizations.previousPageTooltip, + visualDensity: VisualDensity.compact, + onPressed: onPrevious, + ), + TextButton( + onPressed: onJump, + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), + child: Text('${current ?? '-'} / ${total ?? '-'}'), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + tooltip: localizations.nextPageTooltip, + visualDensity: VisualDensity.compact, + onPressed: onNext, + ), + ], + ), + ); + } +} diff --git a/lib/widgets/card/bounty_answer_card.dart b/lib/widgets/card/bounty_answer_card.dart index 41f0591da..470127991 100644 --- a/lib/widgets/card/bounty_answer_card.dart +++ b/lib/widgets/card/bounty_answer_card.dart @@ -4,6 +4,7 @@ import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart'; /// Widget to show the answer of a bounty in thread. @@ -31,42 +32,43 @@ class BountyAnswerCard extends StatelessWidget { @override Widget build(BuildContext context) { - final secondaryColor = Theme.of(context).colorScheme.secondary; - return Card( + final textTheme = Theme.of(context).textTheme; + // The accepted answer: who answered (opens the profile) and the answer quoted under it. + return AppEmbedCard( + icon: Icons.verified, + title: context.t.bountyAnswerCard.title, margin: EdgeInsets.zero, - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: () async => context.dispatchAsUrl(userSpaceUrl), + child: Row( children: [ - Icon(Icons.verified, size: 28, color: secondaryColor), - sizedBoxW12H12, - Text( - context.t.bountyAnswerCard.title, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: secondaryColor), - ), - ], - ), - sizedBoxW12H12, - ListTile( - contentPadding: EdgeInsets.zero, - leading: GestureDetector( - onTap: () async => context.dispatchAsUrl(userSpaceUrl), - child: CircleAvatar( + CircleAvatar( + radius: 16, backgroundImage: CachedImageProvider( userAvatarUrl, fallbackImageUrl: noAvatarUrl, usage: ImageUsageInfoUserAvatar(username), ), ), - ), - title: GestureDetector(onTap: () async => context.dispatchAsUrl(userSpaceUrl), child: Text(username)), + sizedBoxW8H8, + Expanded( + child: Text(username, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + ), + ], + ), + ), + sizedBoxW8H8, + SizedBox( + width: double.infinity, + child: AppInsetBlock( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Text(answer, style: textTheme.bodyMedium), ), - Text(answer), - ], - ), + ), + ], ), ); } diff --git a/lib/widgets/card/bounty_card.dart b/lib/widgets/card/bounty_card.dart index 73ab57a2c..d90b33bc3 100644 --- a/lib/widgets/card/bounty_card.dart +++ b/lib/widgets/card/bounty_card.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:tsdm_client/constants/layout.dart'; -import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Widget showing a bounty info in thread. class BountyCard extends StatelessWidget { @@ -32,7 +32,7 @@ class BountyCard extends StatelessWidget { children: [ Icon(Icons.done, color: secondaryColor), sizedBoxW4H4, - Text(context.t.bountyCard.resolved, style: bountyStatusTextResolvedStyle), + Flexible(child: Text(context.t.bountyCard.resolved, style: bountyStatusTextResolvedStyle)), ], ); } else { @@ -41,41 +41,36 @@ class BountyCard extends StatelessWidget { children: [ Icon(Icons.pending, color: tertiaryColor), sizedBoxW4H4, - Text(context.t.bountyCard.processing, style: bountyStatusTextStyle), + Flexible(child: Text(context.t.bountyCard.processing, style: bountyStatusTextStyle)), ], ); } - return Card( + // State (resolved / in progress) under the title, the reward as a highlighted line; both wrap with large text. + return AppEmbedCard( + icon: Icons.emoji_events_outlined, + title: context.t.bountyCard.title, margin: EdgeInsets.zero, - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: ConstrainedBox( - constraints: const BoxConstraints(minWidth: 100), - child: Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.t.bountyCard.title, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: secondaryColor), - ), - sizedBoxW24H24, - bountyStatusWidget, - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(FontAwesomeIcons.coins, size: 20), - sizedBoxW4H4, - Text(context.t.bountyCard.price(price: price), style: Theme.of(context).textTheme.bodyLarge), - ], - ), - ].insertBetween(sizedBoxW12H12), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + bountyStatusWidget, + AppInsetBlock( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(FontAwesomeIcons.coins, size: 18, color: Theme.of(context).colorScheme.primary), + sizedBoxW8H8, + Flexible( + child: Text(context.t.bountyCard.price(price: price), style: Theme.of(context).textTheme.bodyLarge), + ), + ], + ), ), - ), + ], ), ); } diff --git a/lib/widgets/card/code_card.dart b/lib/widgets/card/code_card.dart index 0f1898469..9268ecfa8 100644 --- a/lib/widgets/card/code_card.dart +++ b/lib/widgets/card/code_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/utils/clipboard.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Card to display code block, from html node type
. class CodeCard extends StatelessWidget { @@ -11,40 +12,29 @@ class CodeCard extends StatelessWidget { /// Code text to show. final String code; - /// Elevation of this card. - /// - /// Default is 1, equal to default card elevation. + /// Nesting elevation given by the HTML renderer; a nested block gets a higher surface container, no shadow. final double? elevation; @override Widget build(BuildContext context) { - final primaryColor = Theme.of(context).colorScheme.primary; - return Card( - elevation: elevation, - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.code_outlined, color: primaryColor), - sizedBoxW8H8, - Text( - context.t.codeCard.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: primaryColor), - ), - ], - ), - sizedBoxW16H16, - Text(code), - sizedBoxW16H16, - OutlinedButton.icon( - icon: const Icon(Icons.copy_outlined), - label: Text(context.t.codeCard.copy), - onPressed: () async => copyToClipboard(context, code), - ), - ], + final colorScheme = Theme.of(context).colorScheme; + // Code sits in its own darker block under the header; copy stays one tap away in the header. + return AppEmbedCard( + icon: Icons.code_outlined, + title: context.t.codeCard.title, + color: appEmbedColor(context, elevation), + // An icon button: keeps the header from overflowing with large text or long translations. + trailing: IconButton( + icon: const Icon(Icons.copy_outlined), + tooltip: context.t.codeCard.copy, + onPressed: () async => copyToClipboard(context, code), + ), + child: SizedBox( + width: double.infinity, + child: AppInsetBlock( + color: colorScheme.surfaceContainerHighest, + padding: edgeInsetsL12T12R12B12, + child: Text(code, style: Theme.of(context).textTheme.bodyMedium), ), ), ); diff --git a/lib/widgets/card/error_card.dart b/lib/widgets/card/error_card.dart index 7fe743b28..e54f39d13 100644 --- a/lib/widgets/card/error_card.dart +++ b/lib/widgets/card/error_card.dart @@ -6,6 +6,7 @@ import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Show error and retry. class ErrorCard extends StatelessWidget { @@ -25,25 +26,35 @@ class ErrorCard extends StatelessWidget { return Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: cardWidth), - child: Card( - margin: EdgeInsets.zero, - child: Padding( - padding: edgeInsetsL24T24R24B24, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline_outlined, - size: math.min(cardWidth - 12 - 12, 80), - color: Theme.of(context).colorScheme.error, + // The rounded surface and icon block shared with [AppStateView]. + child: AppSurface( + padding: edgeInsetsL24T24R24B24, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: BorderRadius.circular(appSurfaceRadius), ), - Text( - message ?? getIt.get().error() ?? context.t.general.failedToLoad, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.outline), + child: SizedBox.square( + dimension: (cardWidth - 48).clamp(0, 64).toDouble(), + child: Icon( + Icons.error_outline_outlined, + size: 32, + color: Theme.of(context).colorScheme.onErrorContainer, + ), ), - Center(child: child), - ].insertBetween(sizedBoxW12H12), - ), + ), + Text( + message ?? getIt.get().error() ?? context.t.general.failedToLoad, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Center(child: child), + ].insertBetween(sizedBoxW12H12), ), ), ), diff --git a/lib/widgets/card/forum_card.dart b/lib/widgets/card/forum_card.dart index 7322b78da..580805d12 100644 --- a/lib/widgets/card/forum_card.dart +++ b/lib/widgets/card/forum_card.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tsdm_client/constants/layout.dart'; @@ -8,55 +10,252 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; -import 'package:tsdm_client/themes/widget_themes.dart'; import 'package:tsdm_client/utils/logger.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/network_indicator_image.dart'; /// Card to show forum information. +/// +/// Header (icon, name, time of the latest thread), counters as pills, then the optional shortcuts (latest thread, +/// links and sub forums) in inner blocks. +/// +/// [large] is for the forum lists of desktop windows (see [forumCardListLayout]): the forum picture (up to 240x120, +/// fitted whole) and a 24px name share the card width, and the three counters are equal blocks ([ForumCardStat]) +/// filling the bottom. Phones (even wide landscape ones) and medium windows keep the compact card. class ForumCard extends StatefulWidget { /// Constructor. - const ForumCard(this.forum, {super.key}); + const ForumCard(this.forum, {this.large = false, super.key}); /// Forum id. final Forum forum; + /// Show the card large, see [ForumCard]. + final bool large; + @override State createState() => _ForumCardState(); } +/// Size of the forum picture of a compact [ForumCard]. +const forumCardImageSize = Size(88, 44); + +/// Largest forum picture of a large [ForumCard] (2:1); the picture is fitted whole (contain), enlarged when smaller. +const forumCardLargeImageSize = Size(240, 120); + +/// Narrowest forum picture shown beside the name of a large [ForumCard]. +const forumCardLargeImageMinWidth = 180.0; + +/// Gap between the picture and the name of a large [ForumCard]. +const forumCardLargeImageGap = 20.0; + +/// Room the name of a large [ForumCard] needs beside the picture at 1x text; below it the name goes under the picture. +const forumCardLargeNameMinWidth = 220.0; + +/// Picture size of a large [ForumCard] whose content is [contentWidth] wide, and whether the name fits beside it. +/// +/// Beside the name the picture takes 42% of the card (180 to 240 wide). When the name would get less than +/// [forumCardLargeNameMinWidth] (scaled by [textScale]), the picture keeps its largest size and the name goes under it. +({Size image, bool sideBySide}) forumCardLargeHeaderLayout(double contentWidth, double textScale) { + final ratio = forumCardLargeImageSize.height / forumCardLargeImageSize.width; + final beside = (contentWidth * 0.42).clamp(forumCardLargeImageMinWidth, forumCardLargeImageSize.width); + if (contentWidth - beside - forumCardLargeImageGap >= forumCardLargeNameMinWidth * textScale) { + return (image: Size(beside, beside * ratio), sideBySide: true); + } + final stacked = math.min(contentWidth, forumCardLargeImageSize.width); + return (image: Size(stacked, stacked * ratio), sideBySide: false); +} + +/// Padding of a large [ForumCard]. +const forumCardLargePadding = EdgeInsets.symmetric(horizontal: 24, vertical: 20); + +/// Widest a list of [ForumCard]s grows once it shows large cards (other lists, and forum lists below +/// [forumCardListLargeWidth], keep [appListMaxWidth]). +const forumCardListMaxWidth = 1520.0; + +/// Width a list of [ForumCard]s must really have (measured: navigation and safe area excluded) to show large cards. +/// +/// Below it the list keeps the compact cards of phones and tablets (one column, then two from [appTwoColumnWidth]). +const forumCardListLargeWidth = 1100.0; + +/// Whether [platform] is a desktop one. Only desktop windows get large [ForumCard]s: phones and tablets keep the +/// compact cards at any width, landscape included. +bool forumCardListIsDesktop(TargetPlatform platform) => switch (platform) { + TargetPlatform.windows || TargetPlatform.linux || TargetPlatform.macOS => true, + TargetPlatform.android || TargetPlatform.iOS || TargetPlatform.fuchsia => false, +}; + +/// Whether a list of [ForumCard]s shows large cards in a page of [width] on [platform]. +bool forumCardListUsesLargeCards(double width, TargetPlatform platform) => + forumCardListIsDesktop(platform) && width >= forumCardListLargeWidth; + +/// Layout of a list of [ForumCard]s in a page of [width] on [platform] (topics, sub forums of a forum, forum group). +/// +/// Desktop windows get a wider content area (still two columns) with large cards, so the list is not a small island +/// in the middle of the window; phones (any width), tablets and medium windows keep the shared list width and the +/// compact cards. Pass `Theme.of(context).platform`. +({bool large, int columns, EdgeInsets side, double gap, Widget separator}) forumCardListLayout( + double width, + TargetPlatform platform, +) { + final large = forumCardListUsesLargeCards(width, platform); + return ( + large: large, + columns: appColumnsFor(width), + side: appCenteredPadding(width, maxWidth: large ? forumCardListMaxWidth : appListMaxWidth), + gap: large ? appSurfaceGap : appSurfaceGapCompact, + separator: large ? const SizedBox(height: appSurfaceGap) : appListSeparator, + ); +} + +/// One counter of a large [ForumCard]: icon and caption on top, the number below; the three share the card width. +/// +/// The number is never cut: it scales down when a narrow block with large text cannot hold it. +class ForumCardStat extends StatelessWidget { + /// Constructor. + const ForumCardStat({ + required this.icon, + required this.caption, + required this.value, + required this.tooltip, + super.key, + }); + + /// Icon. + final IconData icon; + + /// Short caption ("Threads"). + final String caption; + + /// The number. + final String value; + + /// Tooltip and semantic label. + final String tooltip; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Tooltip( + message: tooltip, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: colorScheme.primary), + sizedBoxW8H8, + Expanded( + child: Text( + caption, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith(fontSize: 15, color: colorScheme.onSurfaceVariant), + ), + ), + ], + ), + sizedBoxW4H4, + FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text( + value, + maxLines: 1, + style: textTheme.titleLarge?.copyWith( + fontSize: 20, + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Caption of a counter from a tooltip prefix like "Threads: ". +String _statCaption(String prefix) => prefix.replaceAll(RegExp(r'[::]\s*$'), '').trim(); + final class _ForumCardState extends State with LoggerMixin { bool showingSubThread = false; bool showingSubForum = false; + Future _openUrl(String? url) async { + final target = url?.parseUrlToRoute(); + if (target == null) { + error('invalid forum card url: $url'); + return; + } + await context.pushNamed( + target.screenPath, + pathParameters: target.pathParameters, + queryParameters: target.queryParameters, + ); + } + Widget _buildShortcut(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final large = widget.large; return Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.forum.isExpanded) - ListTile( - title: Row( - children: [ - Text(widget.forum.latestThreadTitle ?? '', style: Theme.of(context).textTheme.labelMedium), - Expanded(child: Container()), - Text(widget.forum.latestThreadUserName ?? '', style: Theme.of(context).textTheme.labelSmall), - ], + if (widget.forum.isExpanded && (widget.forum.latestThreadTitle?.isNotEmpty ?? false)) ...[ + sizedBoxW8H8, + // Latest thread: an inner block, title on the start, author on the end. + Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(appInnerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async => _openUrl(widget.forum.latestThreadUrl), + child: Padding( + padding: edgeInsetsL12T8R12B8, + child: Row( + children: [ + Icon(Icons.subdirectory_arrow_right_outlined, size: 16, color: colorScheme.outline), + sizedBoxW8H8, + Expanded( + child: Text( + widget.forum.latestThreadTitle ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: large ? textTheme.bodyMedium : textTheme.labelMedium, + ), + ), + if (widget.forum.latestThreadUserName?.isNotEmpty ?? false) ...[ + sizedBoxW8H8, + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + widget.forum.latestThreadUserName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: (large ? textTheme.labelMedium : textTheme.labelSmall)?.copyWith( + color: colorScheme.outline, + ), + ), + ), + ], + ], + ), + ), ), - onTap: () async { - final target = widget.forum.latestThreadUrl?.parseUrlToRoute(); - if (target == null) { - error( - 'invalid latest thread url: ' - '${widget.forum.latestThreadUrl}', - ); - return; - } - await context.pushNamed( - target.screenPath, - pathParameters: target.pathParameters, - queryParameters: target.queryParameters, - ); - }, ), + ], if (widget.forum.subThreadList?.isNotEmpty ?? false) ..._buildWrapSection(context, context.t.forumCard.links, widget.forum.subThreadList!, showingSubThread, () { setState(() { @@ -84,121 +283,250 @@ final class _ForumCardState extends State with LoggerMixin { .map( (e) => ActionChip( label: Text(e.$1), - labelStyle: Theme.of(context).textTheme.labelSmall, - shape: LinearBorder.start(), - onPressed: () async { - final target = e.$2.parseUrlToRoute(); - if (target == null) { - debug('invalid url : ${e.$2}'); - return; - } - await context.pushNamed( - target.screenPath, - pathParameters: target.pathParameters, - queryParameters: target.queryParameters, - ); - }, + labelStyle: widget.large ? Theme.of(context).textTheme.labelMedium : Theme.of(context).textTheme.labelSmall, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + onPressed: () async => _openUrl(e.$2), ), ) .toList(); return [ - ListTile( - title: Text(title, style: Theme.of(context).textTheme.titleSmall), - trailing: Icon(state ? Icons.expand_less : Icons.expand_more), + sizedBoxW4H4, + // Collapsible section: title with the number of entries and the expand mark. + InkWell( + borderRadius: BorderRadius.circular(appInnerRadius), onTap: onPressed, - ), - if (state) - Padding( - padding: edgeInsetsL8R8, + child: Padding( + padding: edgeInsetsT4B4, child: Row( children: [ Expanded( - child: Wrap(spacing: 8, runSpacing: 8, runAlignment: WrapAlignment.end, children: wrapChildren), + child: Text( + '$title (${dataList.length})', + style: + (widget.large ? Theme.of(context).textTheme.titleMedium : Theme.of(context).textTheme.titleSmall) + ?.copyWith(fontWeight: FontWeight.w600), + ), ), + Icon(state ? Icons.expand_less : Icons.expand_more), ], ), ), + ), + if (state) + Padding( + padding: edgeInsetsT4B4, + child: Wrap(spacing: 8, runSpacing: 8, children: wrapChildren), + ), ]; } @override Widget build(BuildContext context) { final settingsStream = getIt.get().settings; - final forumInfoList = [ - (Icons.forum_outlined, widget.forum.threadCount), - (Icons.chat_outlined, widget.forum.replyCount), - (Icons.mark_chat_unread_outlined, widget.forum.threadTodayCount ?? 0), - ]; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final tr = context.t.topicPage; + final large = widget.large; - final forumInfoWidgets = forumInfoList - .map( - (e) => Expanded( - child: Row( + final shortcut = StreamBuilder( + stream: settingsStream, + builder: (context, settings) { + if (!settings.hasData) { + return const SizedBox.shrink(); + } + if (settings.data!.showShortcutInForumCard) { + return _buildShortcut(context); + } + return const SizedBox.shrink(); + }, + ); + + Future openForum() async { + await context.pushNamed( + ScreenPaths.forum, + pathParameters: {'fid': '${widget.forum.forumID}'}, + queryParameters: {'appBarTitle': widget.forum.name}, + ); + } + + if (large) { + return AppSurface( + padding: forumCardLargePadding, + onTap: openForum, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildLargeHeader(context), + const SizedBox(height: 18), + // Three equal blocks filling the bottom of the card. + Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(e.$1, size: smallIconSize), - sizedBoxW4H4, - Flexible( - child: Text( - '${e.$2}', - style: const TextStyle(fontSize: smallTextSize), - maxLines: 1, - overflow: TextOverflow.clip, + Expanded( + child: ForumCardStat( + icon: Icons.forum_outlined, + caption: _statCaption(tr.threads), + value: '${widget.forum.threadCount}', + tooltip: '${tr.threads}${widget.forum.threadCount}', + ), + ), + sizedBoxW12H12, + Expanded( + child: ForumCardStat( + icon: Icons.chat_outlined, + caption: _statCaption(tr.posts), + value: '${widget.forum.replyCount}', + tooltip: '${tr.posts}${widget.forum.replyCount}', + ), + ), + sizedBoxW12H12, + Expanded( + child: ForumCardStat( + icon: Icons.mark_chat_unread_outlined, + caption: _statCaption(tr.today), + value: '${widget.forum.threadTodayCount ?? 0}', + tooltip: '${tr.today}${widget.forum.threadTodayCount ?? 0}', ), ), ], ), - ), - ) - .toList(); - - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () async { - await context.pushNamed( - ScreenPaths.forum, - pathParameters: {'fid': '${widget.forum.forumID}'}, - queryParameters: {'appBarTitle': widget.forum.name}, - ); - }, - child: Column( - children: [ - ListTile( - leading: SizedBox(width: 100, height: 50, child: NetworkIndicatorImage(widget.forum.iconUrl)), - title: Text(widget.forum.name, style: Theme.of(context).textTheme.titleMedium, maxLines: 2), - subtitle: widget.forum.latestThreadTime != null - ? Text( - widget.forum.latestThreadTime!.elapsedTillNow(context), - style: TextStyle(color: Theme.of(context).colorScheme.secondary), - ) - : null, - ), - StreamBuilder( - stream: settingsStream, - builder: (context, settings) { - if (!settings.hasData) { - return const SizedBox.shrink(); - } - if (settings.data!.showShortcutInForumCard) { - return _buildShortcut(context); - } - return const SizedBox.shrink(); - }, - ), - Padding( - padding: edgeInsetsL16R16B12, - child: Column( - children: [ - sizedBoxW12H12, - Row(children: forumInfoWidgets), - ], - ), - ), + shortcut, ], ), + ); + } + + return AppSurface( + padding: edgeInsetsL12T12R12B12, + onTap: openForum, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: SizedBox.fromSize( + size: forumCardImageSize, + child: NetworkIndicatorImage(widget.forum.iconUrl), + ), + ), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.forum.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600), + ), + if (widget.forum.latestThreadTime != null) + Text( + widget.forum.latestThreadTime!.elapsedTillNow(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelSmall?.copyWith(color: colorScheme.secondary), + ), + ], + ), + ), + ], + ), + sizedBoxW12H12, + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + AppInfoPill( + icon: Icons.forum_outlined, + label: '${widget.forum.threadCount}', + tooltip: '${tr.threads}${widget.forum.threadCount}', + ), + AppInfoPill( + icon: Icons.chat_outlined, + label: '${widget.forum.replyCount}', + tooltip: '${tr.posts}${widget.forum.replyCount}', + ), + AppInfoPill( + icon: Icons.mark_chat_unread_outlined, + label: '${widget.forum.threadTodayCount ?? 0}', + tooltip: '${tr.today}${widget.forum.threadTodayCount ?? 0}', + ), + ], + ), + shortcut, + ], ), ); } + + /// Picture and name of a large card, laid out from the real card width and text scale (see + /// [forumCardLargeHeaderLayout]): side by side, or the name under the picture on narrow cards with large text. + Widget _buildLargeHeader(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final textScale = MediaQuery.textScalerOf(context).scale(24) / 24; + + final name = Text( + widget.forum.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.headlineSmall?.copyWith(fontSize: 24, fontWeight: FontWeight.w600, height: 1.3), + ); + final time = widget.forum.latestThreadTime == null + ? null + : Row( + children: [ + Icon(Icons.schedule_outlined, size: 18, color: colorScheme.secondary), + const SizedBox(width: 6), + Flexible( + child: Text( + widget.forum.latestThreadTime!.elapsedTillNow(context), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith(fontSize: 16, color: colorScheme.secondary), + ), + ), + ], + ); + final nameBlock = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + name, + if (time != null) ...[sizedBoxW4H4, time], + ], + ); + + return LayoutBuilder( + builder: (context, constraints) { + final layout = forumCardLargeHeaderLayout(constraints.maxWidth, textScale); + final picture = ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: SizedBox.fromSize( + size: layout.image, + // Fit the whole picture and let a small one grow with the box. + child: NetworkIndicatorImage(widget.forum.iconUrl, fit: BoxFit.contain), + ), + ); + if (layout.sideBySide) { + return Row( + children: [ + picture, + const SizedBox(width: forumCardLargeImageGap), + Expanded(child: nameBlock), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [picture, sizedBoxW12H12, nameBlock], + ); + }, + ); + } } diff --git a/lib/widgets/card/lock_card/locked_card.dart b/lib/widgets/card/lock_card/locked_card.dart index 6f81dc361..3ca06b8ba 100644 --- a/lib/widgets/card/lock_card/locked_card.dart +++ b/lib/widgets/card/lock_card/locked_card.dart @@ -23,6 +23,7 @@ import 'package:tsdm_client/shared/providers/net_client_provider/net_client_prov import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -48,7 +49,7 @@ class LockedCard extends StatefulWidget { /// Locked area model. final Locked locked; - /// Elevation of this card. + /// Nesting elevation given by the HTML renderer; a nested block gets a higher surface container, no shadow. final double? elevation; @override @@ -193,15 +194,12 @@ class _LockedCardState extends State with LoggerMixin { final tr = context.t.lockedCard; final widgets = []; - final primaryStyle = Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.primary); final secondaryStyle = Theme.of(context).textTheme.bodySmall; - final Text title; + final String title; if (widget.locked.lockedWithPoints) { - title = Text(tr.points.title, style: primaryStyle); + title = tr.points.title; widgets.addAll([ Text( widget.locked.points != null @@ -211,7 +209,7 @@ class _LockedCardState extends State with LoggerMixin { ), ]); } else if (widget.locked.lockedWithPurchase) { - title = Text(tr.purchase.title, style: primaryStyle); + title = tr.purchase.title; widgets.addAll([ if (widget.locked.purchasedCount != null) Text.rich( @@ -222,12 +220,12 @@ class _LockedCardState extends State with LoggerMixin { _buildPurchaseBody(context), ]); } else if (widget.locked.lockedWithReply) { - title = Text(tr.reply.title, style: primaryStyle); + title = tr.reply.title; widgets.addAll([ Text.rich(tr.reply.detail(reply: _buildUnderlineText(context, tr.reply.detailReply)), style: secondaryStyle), ]); } else if (widget.locked.lockedWithAuthor) { - title = Text(tr.author.title, style: primaryStyle); + title = tr.author.title; widgets.addAll([ Text.rich( tr.author.detail(author: _buildUnderlineText(context, tr.author.detailAuthor)), @@ -235,10 +233,10 @@ class _LockedCardState extends State with LoggerMixin { ), ]); } else if (widget.locked.lockedWithBlocked) { - title = Text(tr.blocked.title, style: primaryStyle); + title = tr.blocked.title; widgets.add(Text(tr.blocked.detail)); } else if (widget.locked.lockedWithSale) { - title = Text(tr.sale.title, style: primaryStyle); + title = tr.sale.title; widgets.addAll([ // Sales count is not available for moderators on Discuz X5, only show the price line in that case. Text( @@ -246,83 +244,101 @@ class _LockedCardState extends State with LoggerMixin { ? tr.sale.detail(price: '${widget.locked.price!}', count: '${widget.locked.purchasedCount!}') : tr.sale.detail(price: '${widget.locked.price!}', count: '').split('\n').first, ), - OutlinedButton( - child: Text(tr.sale.viewLog), - onPressed: () async { - await showDialog( - context: context, - builder: (_) => RootPage( - DialogPaths.showThreadSalesHistory, - CustomAlertDialog.future( - title: Text(tr.sale.dialog.title), - future: _fetchSalesHistory(widget.locked.tid!), - successBuilder: (context, snapshot) { - final salesHistory = snapshot; - if (salesHistory.isEmpty) { - return Text( - context.t.general.noData, - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.outline), - ); - } + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + icon: const Icon(Icons.receipt_long_outlined), + label: Text(tr.sale.viewLog), + onPressed: () async { + await showDialog( + context: context, + builder: (_) => RootPage( + DialogPaths.showThreadSalesHistory, + CustomAlertDialog.future( + title: Row( + children: [ + const AppIconTile(Icons.receipt_long_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(tr.sale.dialog.title)), + ], + ), + future: _fetchSalesHistory(widget.locked.tid!), + successBuilder: (context, snapshot) { + final salesHistory = snapshot; + if (salesHistory.isEmpty) { + return AppStateView(message: context.t.general.noData, scrollable: false); + } - return Column( - mainAxisSize: MainAxisSize.min, - children: salesHistory - .map( - (v) => ListTile( - leading: HeroUserAvatar(username: v.username, avatarUrl: null, disableHero: true), - title: Text(v.username), - isThreeLine: true, - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - SingleLineText(v.time.yyyyMMDDHHMMSS()), - SingleLineText(tr.sale.dialog.price(price: v.price)), - ], + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // One tappable block per buyer: avatar, name and time, the price at the end. + return Column( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: salesHistory + .map( + (v) => Material( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(appInnerRadius), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () async => + context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': v.uid}), + child: Padding( + padding: edgeInsetsL12T8R12B8, + child: Row( + children: [ + HeroUserAvatar(username: v.username, avatarUrl: null, disableHero: true), + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SingleLineText( + v.username, + style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + SingleLineText( + v.time.yyyyMMDDHHMMSS(), + style: textTheme.labelSmall?.copyWith(color: colorScheme.outline), + ), + ], + ), + ), + sizedBoxW8H8, + Text( + tr.sale.dialog.price(price: v.price), + style: textTheme.labelLarge?.copyWith(color: colorScheme.primary), + ), + ], + ), + ), + ), ), - onTap: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': v.uid}), - ), - ) - .toList(), - ); - }, + ) + .toList(), + ); + }, + ), ), - ), - ); - }, + ); + }, + ), ), ]); } else { throw UnimplementedError('Widget for card type of locked card not implemented'); } - return Card( - elevation: widget.elevation, - clipBehavior: Clip.antiAlias, - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.locked.lockedWithSale) - Icon(Icons.sell_outlined, color: Theme.of(context).colorScheme.primary) - else - Icon(Icons.lock_outlined, color: Theme.of(context).colorScheme.primary), - sizedBoxW8H8, - title, - ], - ), - sizedBoxW8H8, - ...widgets.insertBetween(sizedBoxW8H8), - ], - ), + return AppEmbedCard( + icon: widget.locked.lockedWithSale ? Icons.sell_outlined : Icons.lock_outlined, + title: title, + color: appEmbedColor(context, widget.elevation), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: widgets.insertBetween(sizedBoxW8H8), ), ); } diff --git a/lib/widgets/card/notice_card_v2.dart b/lib/widgets/card/notice_card_v2.dart index 22337ccbd..eb1113b06 100644 --- a/lib/widgets/card/notice_card_v2.dart +++ b/lib/widgets/card/notice_card_v2.dart @@ -22,6 +22,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/clipboard.dart'; import 'package:tsdm_client/utils/html/munch_options.dart'; import 'package:tsdm_client/utils/show_dialog.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/munched_html.dart'; @@ -82,144 +83,127 @@ class _NoticeCardV2State extends State { return const SizedBox.shrink(); } final showBadge = getIt.get().currentSettings.showUnreadNoticeBadge; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: Badge( - isLabelVisible: showBadge && !widget.data.alreadyRead, - child: const CircleAvatar(child: Icon(Icons.notifications_outlined)), - ), - title: Text(context.t.noticePage.noticeTab.title), - subtitle: Text( - // Timestamp in second. - DateTime.fromMillisecondsSinceEpoch(widget.data.timestamp * 1000).yyyyMMDDHHMMSS(), - ), - trailing: PopupMenuButton( - itemBuilder: (_) => [ - if (!widget.data.alreadyRead) - PopupMenuItem( - value: _Actions.markAsRead, - child: Row( - children: [ - const Icon(Icons.mark_chat_read_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.markAsRead), - ], - ), - ), - if (widget.data.alreadyRead) - PopupMenuItem( - value: _Actions.markAsUnread, - child: Row( - children: [ - const Icon(Icons.mark_chat_unread_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.markAsUnread), - ], - ), - ), - PopupMenuItem( - value: _Actions.deleteItem, - child: Row( - children: [ - Icon(Icons.delete_forever_outlined, color: Theme.of(context).colorScheme.error), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.delete.title, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], - ), - ), - // Only offered when the notice's own ignore link names its author and type. System notices (author 0) - // can only be ignored for everybody on the forum, there is no user to block. - if (widget.data.authorId != null && widget.data.ignoreType != null) ...>[ - const PopupMenuDivider(), - // Not listening: the menu is built on tap, outside of the build phase. - if (widget.data.authorId! > 0 && - widget.data.authorId != currentBlockList(context, listen: false).ownerUid) - PopupMenuItem( - value: _Actions.blockAuthor, - child: Row( - children: [ - const Icon(Icons.block_outlined), - sizedBoxPopupMenuItemIconSpacing, - Expanded(child: Text(context.t.userBlock.block)), - ], - ), - ), - PopupMenuItem( - value: _Actions.ignoreOnForum, - child: Row( - children: [ - const Icon(Icons.notifications_off_outlined), - sizedBoxPopupMenuItemIconSpacing, - Expanded(child: Text(context.t.userBlock.serverRules.entry)), - ], - ), - ), - ] else ...>[ - // Without the ignore link there is nothing to act on; say so instead of silently dropping the entries - // above. - const PopupMenuDivider(), - PopupMenuItem<_Actions>(enabled: false, child: Text(context.t.userBlock.serverRules.notAvailable)), + final unread = showBadge && !widget.data.alreadyRead; + return _NotificationCardShell( + highlight: unread, + leading: Badge(isLabelVisible: unread, child: const AppIconTile(Icons.notifications_outlined)), + title: Text(context.t.noticePage.noticeTab.title), + timestamp: widget.data.timestamp, + menu: PopupMenuButton( + itemBuilder: (_) => [ + if (!widget.data.alreadyRead) + PopupMenuItem( + value: _Actions.markAsRead, + child: Row( + children: [ + const Icon(Icons.mark_chat_read_outlined), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.markAsRead), ], - if (context.read().state.settingsMap.enableDebugOperations) ...>[ - const PopupMenuDivider(), - PopupMenuItem(value: _Actions.copyRawContent, child: Text(tr.copyRawContent)), + ), + ), + if (widget.data.alreadyRead) + PopupMenuItem( + value: _Actions.markAsUnread, + child: Row( + children: [ + const Icon(Icons.mark_chat_unread_outlined), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.markAsUnread), ], + ), + ), + PopupMenuItem( + value: _Actions.deleteItem, + child: Row( + children: [ + Icon(Icons.delete_forever_outlined, color: Theme.of(context).colorScheme.error), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.delete.title, style: TextStyle(color: Theme.of(context).colorScheme.error)), ], - onSelected: (value) async { - switch (value) { - case _Actions.blockAuthor: - // The notice names no reliable username, the management page shows the uid. - final authorId = widget.data.authorId!; - await confirmAndBlockUser(context, uid: authorId, username: 'UID $authorId'); - case _Actions.ignoreOnForum: - await showNoticeIgnoreDialog( - context, - NoticeIgnoreTarget(type: widget.data.ignoreType!, authorId: widget.data.authorId!), - ); - case _Actions.markAsRead: - _onUrlLaunched(markAsRead: true); - case _Actions.markAsUnread: - _onUrlLaunched(markAsRead: false); - case _Actions.deleteItem: - final tr = context.t.noticePage.cardMenu.delete; - final result = await showQuestionDialog( - context: context, - title: tr.title, - message: tr.detail, - dangerous: true, - ); - if (!context.mounted || result == null || !result) { - return; - } - - if (!widget.data.alreadyRead) { - context.read().decreaseNotice(); - } - context.read().add( - NotificationDeleteNoticeRequested( - uid: context.read().currentUser!.uid!, - nid: widget.data.id, - ), - ); - case _Actions.copyRawContent: - await copyToClipboard(context, widget.data.data); - } - }, ), ), - Padding( - padding: edgeInsetsL16R16B12, - child: MunchedHtml( - widget.data.data, - options: MunchOptions(onUrlLaunched: () => _onUrlLaunched(markAsRead: true)), + // Only offered when the notice's own ignore link names its author and type. System notices (author 0) + // can only be ignored for everybody on the forum, there is no user to block. + if (widget.data.authorId != null && widget.data.ignoreType != null) ...>[ + const PopupMenuDivider(), + // Not listening: the menu is built on tap, outside of the build phase. + if (widget.data.authorId! > 0 && widget.data.authorId != currentBlockList(context, listen: false).ownerUid) + PopupMenuItem( + value: _Actions.blockAuthor, + child: Row( + children: [ + const Icon(Icons.block_outlined), + sizedBoxPopupMenuItemIconSpacing, + Expanded(child: Text(context.t.userBlock.block)), + ], + ), + ), + PopupMenuItem( + value: _Actions.ignoreOnForum, + child: Row( + children: [ + const Icon(Icons.notifications_off_outlined), + sizedBoxPopupMenuItemIconSpacing, + Expanded(child: Text(context.t.userBlock.serverRules.entry)), + ], + ), ), - ), + ] else ...>[ + // Without the ignore link there is nothing to act on; say so instead of silently dropping the entries + // above. + const PopupMenuDivider(), + PopupMenuItem<_Actions>(enabled: false, child: Text(context.t.userBlock.serverRules.notAvailable)), + ], + if (context.read().state.settingsMap.enableDebugOperations) ...>[ + const PopupMenuDivider(), + PopupMenuItem(value: _Actions.copyRawContent, child: Text(tr.copyRawContent)), + ], ], + onSelected: (value) async { + switch (value) { + case _Actions.blockAuthor: + // The notice names no reliable username, the management page shows the uid. + final authorId = widget.data.authorId!; + await confirmAndBlockUser(context, uid: authorId, username: 'UID $authorId'); + case _Actions.ignoreOnForum: + await showNoticeIgnoreDialog( + context, + NoticeIgnoreTarget(type: widget.data.ignoreType!, authorId: widget.data.authorId!), + ); + case _Actions.markAsRead: + _onUrlLaunched(markAsRead: true); + case _Actions.markAsUnread: + _onUrlLaunched(markAsRead: false); + case _Actions.deleteItem: + final tr = context.t.noticePage.cardMenu.delete; + final result = await showQuestionDialog( + context: context, + title: tr.title, + message: tr.detail, + dangerous: true, + ); + if (!context.mounted || result == null || !result) { + return; + } + + if (!widget.data.alreadyRead) { + context.read().decreaseNotice(); + } + context.read().add( + NotificationDeleteNoticeRequested( + uid: context.read().currentUser!.uid!, + nid: widget.data.id, + ), + ); + case _Actions.copyRawContent: + await copyToClipboard(context, widget.data.data); + } + }, + ), + body: MunchedHtml( + widget.data.data, + options: MunchOptions(onUrlLaunched: () => _onUrlLaunched(markAsRead: true)), ), ); } @@ -264,122 +248,101 @@ class _PersonalMessageCardV2State extends State { } } - @override - void initState() { - super.initState(); - } - @override Widget build(BuildContext context) { final tr = context.t.noticePage.cardMenu; final showBadge = getIt.get().currentSettings.showUnreadPersonalMessageBadge; // Muted peers keep their conversation in the list, only the unread badge is dropped. final muted = isMutedPersonalMessagePeer(widget.data.peerUid, currentBlockList(context)); + final unread = showBadge && !muted && !widget.data.alreadyRead; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () async => _onTap(context, markAsRead: true, launch: true), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: GestureDetector( - onTap: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': '${widget.data.peerUid}'}), - child: Badge( - isLabelVisible: showBadge && !muted && !widget.data.alreadyRead, - child: HeroUserAvatar(username: widget.data.peerUsername, avatarUrl: null, disableHero: true), - ), - ), - title: GestureDetector( - onTap: () async => - context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': '${widget.data.peerUid}'}), - child: Align(alignment: Alignment.centerLeft, child: Text(widget.data.peerUsername)), - ), - subtitle: Text( - // Timestamp in second. - DateTime.fromMillisecondsSinceEpoch(widget.data.timestamp * 1000).yyyyMMDDHHMMSS(), + Future openProfile() async { + await context.pushNamed(ScreenPaths.profile, queryParameters: {'uid': '${widget.data.peerUid}'}); + } + + return _NotificationCardShell( + highlight: unread, + onTap: () async => _onTap(context, markAsRead: true, launch: true), + leading: GestureDetector( + onTap: openProfile, + child: Badge( + isLabelVisible: unread, + child: HeroUserAvatar(username: widget.data.peerUsername, avatarUrl: null, disableHero: true), + ), + ), + title: GestureDetector(onTap: openProfile, child: Text(widget.data.peerUsername)), + timestamp: widget.data.timestamp, + menu: PopupMenuButton( + itemBuilder: (_) => [ + if (!widget.data.alreadyRead) + PopupMenuItem( + value: _Actions.markAsRead, + child: Row( + children: [ + const Icon(Icons.mark_chat_read_outlined), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.markAsRead), + ], ), - trailing: PopupMenuButton( - itemBuilder: (_) => [ - if (!widget.data.alreadyRead) - PopupMenuItem( - value: _Actions.markAsRead, - child: Row( - children: [ - const Icon(Icons.mark_chat_read_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.markAsRead), - ], - ), - ), - if (widget.data.alreadyRead) - PopupMenuItem( - value: _Actions.markAsUnread, - child: Row( - children: [ - const Icon(Icons.mark_chat_unread_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.markAsUnread), - ], - ), - ), - PopupMenuItem( - value: _Actions.deleteItem, - child: Row( - children: [ - Icon(Icons.delete_forever_outlined, color: Theme.of(context).colorScheme.error), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.delete.title, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], - ), - ), + ), + if (widget.data.alreadyRead) + PopupMenuItem( + value: _Actions.markAsUnread, + child: Row( + children: [ + const Icon(Icons.mark_chat_unread_outlined), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.markAsUnread), ], - onSelected: (value) async { - switch (value) { - case _Actions.markAsRead: - await _onTap(context, markAsRead: true, launch: false); - case _Actions.markAsUnread: - await _onTap(context, markAsRead: false, launch: false); - case _Actions.deleteItem: - final tr = context.t.noticePage.cardMenu.delete; - final result = await showQuestionDialog(context: context, title: tr.title, message: tr.detail); - if (!context.mounted || result == null || !result) { - return; - } - - if (!widget.data.alreadyRead && - !isMutedPersonalMessagePeer(widget.data.peerUid, currentBlockList(context, listen: false))) { - context.read().decreasePersonalMessage(); - } - context.read().add( - NotificationDeletePersonalMessageRequested( - uid: context.read().currentUser!.uid!, - peerUid: widget.data.peerUid, - ), - ); - case _Actions.copyRawContent: - await copyToClipboard(context, widget.data.data); - case _Actions.blockAuthor || _Actions.ignoreOnForum: - // Only offered on notices. - break; - } - }, ), ), - Padding( - padding: edgeInsetsL16R16B12, - // The list only carries a summary of the last message and the server drops the `&` of every url in it - // (`forum.php?mod=viewthreadtid=1`), so a link made of that text is not the one the sender wrote: it is - // unrecognized and would open the browser. Plain text here; the real link is in the conversation the - // card opens (GitHub #46). - child: MunchedHtml(widget.data.data, options: const MunchOptions(renderUrl: false)), + PopupMenuItem( + value: _Actions.deleteItem, + child: Row( + children: [ + Icon(Icons.delete_forever_outlined, color: Theme.of(context).colorScheme.error), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.delete.title, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], ), - ], - ), + ), + ], + onSelected: (value) async { + switch (value) { + case _Actions.markAsRead: + await _onTap(context, markAsRead: true, launch: false); + case _Actions.markAsUnread: + await _onTap(context, markAsRead: false, launch: false); + case _Actions.deleteItem: + final tr = context.t.noticePage.cardMenu.delete; + final result = await showQuestionDialog(context: context, title: tr.title, message: tr.detail); + if (!context.mounted || result == null || !result) { + return; + } + + if (!widget.data.alreadyRead && + !isMutedPersonalMessagePeer(widget.data.peerUid, currentBlockList(context, listen: false))) { + context.read().decreasePersonalMessage(); + } + context.read().add( + NotificationDeletePersonalMessageRequested( + uid: context.read().currentUser!.uid!, + peerUid: widget.data.peerUid, + ), + ); + case _Actions.copyRawContent: + await copyToClipboard(context, widget.data.data); + case _Actions.blockAuthor || _Actions.ignoreOnForum: + // Only offered on notices. + break; + } + }, ), + // The list only carries a summary of the last message and the server drops the `&` of every url in it + // (`forum.php?mod=viewthreadtid=1`), so a link made of that text is not the one the sender wrote: it is + // unrecognized and would open the browser. Plain text here; the real link is in the conversation the + // card opens (GitHub #46). + body: MunchedHtml(widget.data.data, options: const MunchOptions(renderUrl: false)), ); } } @@ -426,97 +389,174 @@ class _BroadcastMessageCardV2State extends State { Widget build(BuildContext context) { final tr = context.t.noticePage.cardMenu; final showBadge = getIt.get().currentSettings.showUnreadBroadcastMessageBadge; - return Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () async => _onTap(context, markAsRead: true, launch: true), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: Badge( - isLabelVisible: showBadge && !widget.data.alreadyRead, - child: const CircleAvatar(child: Icon(Icons.campaign_outlined)), + final unread = showBadge && !widget.data.alreadyRead; + final colorScheme = Theme.of(context).colorScheme; + return _NotificationCardShell( + highlight: unread, + onTap: () async => _onTap(context, markAsRead: true, launch: true), + leading: Badge( + isLabelVisible: unread, + child: AppIconTile( + Icons.campaign_outlined, + color: colorScheme.tertiaryContainer, + foregroundColor: colorScheme.onTertiaryContainer, + ), + ), + title: Text(context.t.noticePage.broadcastMessageTab.system), + timestamp: widget.data.timestamp, + menu: PopupMenuButton( + itemBuilder: (_) => [ + if (!widget.data.alreadyRead) + PopupMenuItem( + value: _Actions.markAsRead, + child: Row( + children: [ + const Icon(Icons.mark_chat_read_outlined), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.markAsRead), + ], ), - title: Text(context.t.noticePage.broadcastMessageTab.system), - subtitle: Text( - // Timestamp in second. - DateTime.fromMillisecondsSinceEpoch(widget.data.timestamp * 1000).yyyyMMDDHHMMSS(), + ), + if (widget.data.alreadyRead) + PopupMenuItem( + value: _Actions.markAsUnread, + child: Row( + children: [ + const Icon(Icons.mark_chat_unread_outlined), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.markAsUnread), + ], ), - trailing: PopupMenuButton( - itemBuilder: (_) => [ - if (!widget.data.alreadyRead) - PopupMenuItem( - value: _Actions.markAsRead, - child: Row( - children: [ - const Icon(Icons.mark_chat_read_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.markAsRead), - ], - ), - ), - if (widget.data.alreadyRead) - PopupMenuItem( - value: _Actions.markAsUnread, - child: Row( - children: [ - const Icon(Icons.mark_chat_unread_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.markAsUnread), - ], + ), + PopupMenuItem( + value: _Actions.deleteItem, + child: Row( + children: [ + Icon(Icons.delete_forever_outlined, color: Theme.of(context).colorScheme.error), + sizedBoxPopupMenuItemIconSpacing, + Text(tr.delete.title, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + ), + ), + ], + onSelected: (value) async { + switch (value) { + case _Actions.markAsRead: + await _onTap(context, markAsRead: true, launch: false); + case _Actions.markAsUnread: + await _onTap(context, markAsRead: false, launch: false); + case _Actions.deleteItem: + final tr = context.t.noticePage.cardMenu.delete; + final result = await showQuestionDialog(context: context, title: tr.title, message: tr.detail); + if (!context.mounted || result == null || !result) { + return; + } + + if (!widget.data.alreadyRead) { + context.read().decreaseBroadcastMessage(); + } + context.read().add( + NotificationDeleteBroadcastMessageRequested( + uid: context.read().currentUser!.uid!, + pmid: widget.data.pmid, + ), + ); + case _Actions.copyRawContent: + await copyToClipboard(context, widget.data.data); + case _Actions.blockAuthor || _Actions.ignoreOnForum: + // Only offered on notices. + break; + } + }, + ), + // Same summary as the personal message list, see above; the detail page has the full message. + body: MunchedHtml(widget.data.data, options: const MunchOptions(renderUrl: false)), + ); + } +} + +/// Layout shared by the notification cards: leading block with the unread badge, title and time, menu, then the +/// content. +/// +/// Unread cards (only when their unread badge is enabled in settings) get a tinted surface and a bolder title, so the +/// state is visible without relying on the small badge alone. +class _NotificationCardShell extends StatelessWidget { + const _NotificationCardShell({ + required this.leading, + required this.title, + required this.timestamp, + required this.menu, + required this.body, + required this.highlight, + this.onTap, + }); + + /// Avatar or icon block. + final Widget leading; + + /// Title, a text; styled here. + final Widget title; + + /// Time of the notification, in seconds. + final int timestamp; + + /// Popup menu of the card. + final Widget menu; + + /// Content. + final Widget body; + + /// Show as unread. + final bool highlight; + + /// Tap on the card. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return AppSurface( + onTap: onTap, + color: highlight + ? Color.alphaBlend(colorScheme.primaryContainer.withValues(alpha: 0.3), colorScheme.surfaceContainerLow) + : null, + padding: const EdgeInsets.fromLTRB(14, 10, 4, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + leading, + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + style: textTheme.titleSmall?.copyWith( + fontWeight: highlight ? FontWeight.bold : FontWeight.w600, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: title, ), - PopupMenuItem( - value: _Actions.deleteItem, - child: Row( - children: [ - Icon(Icons.delete_forever_outlined, color: Theme.of(context).colorScheme.error), - sizedBoxPopupMenuItemIconSpacing, - Text(tr.delete.title, style: TextStyle(color: Theme.of(context).colorScheme.error)), - ], + Text( + // Timestamp in second. + DateTime.fromMillisecondsSinceEpoch(timestamp * 1000).yyyyMMDDHHMMSS(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelSmall?.copyWith(color: colorScheme.outline), ), - ), - ], - onSelected: (value) async { - switch (value) { - case _Actions.markAsRead: - await _onTap(context, markAsRead: true, launch: false); - case _Actions.markAsUnread: - await _onTap(context, markAsRead: false, launch: false); - case _Actions.deleteItem: - final tr = context.t.noticePage.cardMenu.delete; - final result = await showQuestionDialog(context: context, title: tr.title, message: tr.detail); - if (!context.mounted || result == null || !result) { - return; - } - - if (!widget.data.alreadyRead) { - context.read().decreaseBroadcastMessage(); - } - context.read().add( - NotificationDeleteBroadcastMessageRequested( - uid: context.read().currentUser!.uid!, - pmid: widget.data.pmid, - ), - ); - case _Actions.copyRawContent: - await copyToClipboard(context, widget.data.data); - case _Actions.blockAuthor || _Actions.ignoreOnForum: - // Only offered on notices. - break; - } - }, + ], + ), ), - ), - Padding( - padding: edgeInsetsL16R16B12, - // Same summary as the personal message list, see above; the detail page has the full message. - child: MunchedHtml(widget.data.data, options: const MunchOptions(renderUrl: false)), - ), - ], - ), + menu, + ], + ), + sizedBoxW8H8, + Padding(padding: edgeInsetsR12, child: body), + ], ), ); } diff --git a/lib/widgets/card/packet_card.dart b/lib/widgets/card/packet_card.dart index 84548832c..4b5873b00 100644 --- a/lib/widgets/card/packet_card.dart +++ b/lib/widgets/card/packet_card.dart @@ -10,6 +10,7 @@ import 'package:tsdm_client/features/packet/repository/packet_repository.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/show_toast.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// 红包 class PacketCard extends StatelessWidget { @@ -32,7 +33,7 @@ class PacketCard extends StatelessWidget { Widget build(BuildContext context) { final tid = _re.firstMatch(packetUrl)?.namedGroup('tid')?.parseToInt(); - final primaryColor = Theme.of(context).colorScheme.primary; + final colorScheme = Theme.of(context).colorScheme; final tr = context.t.packetCard; return MultiBlocProvider( @@ -78,41 +79,27 @@ class PacketCard extends StatelessWidget { PacketStatus.failed => () async => context.read().receivePacket(packetUrl), }; - return Card( - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(FontAwesomeIcons.coins, color: primaryColor), - sizedBoxW8H8, - Text(tr.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: primaryColor)), - // Some spacing. - sizedBoxW32H32, - sizedBoxW32H32, - sizedBoxW32H32, - IconButton( - icon: Icon(Icons.bar_chart_outlined, color: Theme.of(context).colorScheme.primary), - tooltip: tr.viewReceiveHistory, - onPressed: tid == null - ? null - : () async => - context.pushNamed(ScreenPaths.packetDetail, pathParameters: {'tid': '$tid'}), - ), - ], - ), - sizedBoxW12H12, - Text(tr.detail), - sizedBoxW12H12, - SizedBox( - width: sizeButtonInCardMinWidth, - child: OutlinedButton.icon(icon: body, label: label, onPressed: callback), - ), - ], - ), + // Header with the receive history at the end; the open button is the one filled action of the card. + return AppEmbedCard( + icon: FontAwesomeIcons.coins, + title: tr.title, + trailing: IconButton( + icon: Icon(Icons.bar_chart_outlined, color: colorScheme.primary), + tooltip: tr.viewReceiveHistory, + onPressed: tid == null + ? null + : () async => context.pushNamed(ScreenPaths.packetDetail, pathParameters: {'tid': '$tid'}), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tr.detail, style: Theme.of(context).textTheme.bodyMedium), + sizedBoxW12H12, + ConstrainedBox( + constraints: const BoxConstraints(minWidth: sizeButtonInCardMinWidth), + child: FilledButton.tonalIcon(icon: body, label: label, onPressed: callback), + ), + ], ), ); }, diff --git a/lib/widgets/card/poll_card.dart b/lib/widgets/card/poll_card.dart index c3ff6b4b4..677b95d36 100644 --- a/lib/widgets/card/poll_card.dart +++ b/lib/widgets/card/poll_card.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; @@ -14,6 +15,7 @@ import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/indicator.dart'; /// Ordinary poll embedded in the first post, shared by both thread readers. @@ -81,80 +83,119 @@ class _PollCardState extends State { builder: (context, state) { final tr = context.t.pollVoting; final poll = state.poll; - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.t.pollCard.title, style: Theme.of(context).textTheme.titleMedium), - if (state.busy) const Padding(padding: EdgeInsets.all(12), child: CircularIndicator()), - if (state.submissionUnconfirmed) - Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr.unconfirmed)), - if (state.failed) Text(context.t.general.failedToLoad), - if (poll != null) ...[ - if (poll.summary.isNotEmpty) Text(poll.summary), - if (poll.deadline.isNotEmpty) Text(poll.deadline), - if (poll.availability == PollAvailability.available) - Text(tr.selection(count: state.choices.length, max: poll.maxChoices!)), - if (poll.availability == PollAvailability.available && poll.maxChoices == 1) - RadioGroup( - groupValue: state.choices.firstOrNull, - onChanged: (value) { - if (value != null) _cubit.select(value, selected: true); - }, - child: Column( - children: [ - for (final option in poll.options) - RadioListTile( - value: option.id!, - enabled: !state.busy, - contentPadding: EdgeInsets.zero, - title: Text(option.label), - subtitle: option.result == null ? null : Text(option.result!), - ), - ], + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // Header: title, deadline under it, a spinner while busy. Status lines are banners, choices in the middle, + // actions at the bottom. + return AppEmbedCard( + icon: Icons.poll_outlined, + title: context.t.pollCard.title, + subtitle: (poll?.deadline.isNotEmpty ?? false) ? poll!.deadline : null, + trailing: state.busy ? const SizedBox(width: 20, height: 20, child: CircularIndicator()) : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.submissionUnconfirmed) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AppNoticeBanner(tone: AppNoticeTone.warning, icon: Icons.help_outline, message: tr.unconfirmed), + ), + if (state.failed) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: AppNoticeBanner(tone: AppNoticeTone.error, message: context.t.general.failedToLoad), + ), + if (poll != null) ...[ + if (poll.summary.isNotEmpty) Text(poll.summary, style: textTheme.bodyMedium), + if (poll.availability == PollAvailability.available) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + tr.selection(count: state.choices.length, max: poll.maxChoices!), + style: textTheme.labelMedium?.copyWith(color: colorScheme.primary), + ), + ), + if (poll.availability == PollAvailability.available && poll.maxChoices == 1) + RadioGroup( + groupValue: state.choices.firstOrNull, + onChanged: (value) { + if (value != null) _cubit.select(value, selected: true); + }, + child: Column( + children: [ + for (final option in poll.options) + RadioListTile( + value: option.id!, + enabled: !state.busy, + contentPadding: EdgeInsets.zero, + title: Text(option.label), + subtitle: option.result == null ? null : Text(option.result!), + ), + ], + ), + ) + else ...[ + for (final option in poll.options) + if (poll.availability == PollAvailability.available && option.id != null) + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + title: Text(option.label), + subtitle: option.result == null ? null : Text(option.result!), + value: state.choices.contains(option.id), + onChanged: + state.busy || + (poll.maxChoices != 1 && + !state.choices.contains(option.id) && + state.choices.length >= poll.maxChoices!) + ? null + : (value) => _cubit.select(option.id!, selected: value ?? false), + ) + else + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(option.label), + subtitle: option.result == null ? null : Text(option.result!), ), - ) - else ...[ - for (final option in poll.options) - if (poll.availability == PollAvailability.available && option.id != null) - CheckboxListTile( - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - title: Text(option.label), - subtitle: option.result == null ? null : Text(option.result!), - value: state.choices.contains(option.id), - onChanged: - state.busy || - (poll.maxChoices != 1 && - !state.choices.contains(option.id) && - state.choices.length >= poll.maxChoices!) - ? null - : (value) => _cubit.select(option.id!, selected: value ?? false), - ) - else - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(option.label), - subtitle: option.result == null ? null : Text(option.result!), - ), - ], - if (poll.notice.isNotEmpty) Text(poll.notice), - if (poll.availability != PollAvailability.available) - Text(switch (poll.availability) { - PollAvailability.voted => tr.voted, - PollAvailability.closed => tr.closed, - PollAvailability.loginRequired => tr.loginRequired, - PollAvailability.denied => tr.denied, - _ => tr.unsupported, - }), - if (poll.availability == PollAvailability.available) + ], + if (poll.notice.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: AppNoticeBanner(message: poll.notice), + ), + if (poll.availability != PollAvailability.available) + Padding( + padding: const EdgeInsets.only(top: 4), + child: AppNoticeBanner( + icon: switch (poll.availability) { + PollAvailability.voted => Icons.how_to_vote_outlined, + PollAvailability.closed => Icons.lock_outline, + PollAvailability.loginRequired => Icons.login_outlined, + _ => Icons.info_outline, + }, + message: switch (poll.availability) { + PollAvailability.voted => tr.voted, + PollAvailability.closed => tr.closed, + PollAvailability.loginRequired => tr.loginRequired, + PollAvailability.denied => tr.denied, + _ => tr.unsupported, + }, + ), + ), + ], + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (poll?.availability == PollAvailability.available) + // Plain constructor on purpose: the only FilledButton of the card (widget tests look it up by type). FilledButton( - onPressed: state.busy || !poll.accepts(state.choices) ? null : _cubit.submit, + onPressed: state.busy || !poll!.accepts(state.choices) ? null : _cubit.submit, child: Text(tr.submit), ), - if (poll.availability == PollAvailability.loginRequired) + if (poll?.availability == PollAvailability.loginRequired) TextButton( onPressed: () async { await context.pushNamed(ScreenPaths.login); @@ -162,24 +203,19 @@ class _PollCardState extends State { }, child: Text(tr.loginRequired), ), + TextButton.icon( + onPressed: state.busy ? null : _cubit.load, + icon: const Icon(Icons.refresh), + label: Text(tr.refresh), + ), + OutlinedButton.icon( + icon: const Icon(Icons.open_in_browser_outlined), + label: Text(context.t.pollCard.openInBrowser), + onPressed: () async => context.dispatchAsUrl(_cubit.url, external: true), + ), ], - Wrap( - spacing: 8, - children: [ - TextButton.icon( - onPressed: state.busy ? null : _cubit.load, - icon: const Icon(Icons.refresh), - label: Text(tr.refresh), - ), - OutlinedButton.icon( - icon: const Icon(Icons.open_in_browser_outlined), - label: Text(context.t.pollCard.openInBrowser), - onPressed: () async => context.dispatchAsUrl(_cubit.url, external: true), - ), - ], - ), - ], - ), + ), + ], ), ); }, diff --git a/lib/widgets/card/post_card/post_card.dart b/lib/widgets/card/post_card/post_card.dart index c64262224..579778268 100644 --- a/lib/widgets/card/post_card/post_card.dart +++ b/lib/widgets/card/post_card/post_card.dart @@ -10,6 +10,7 @@ import 'package:tsdm_client/extensions/date_time.dart'; import 'package:tsdm_client/extensions/string.dart'; import 'package:tsdm_client/features/post/models/models.dart'; import 'package:tsdm_client/features/post_report/view/post_report_dialog.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; import 'package:tsdm_client/features/thread/v1/bloc/thread_bloc.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; @@ -20,6 +21,8 @@ import 'package:tsdm_client/shared/models/thread_floor_interaction_mode.dart'; import 'package:tsdm_client/utils/clipboard.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/widgets/adaptive_ink_response.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/card/lock_card/locked_card.dart'; import 'package:tsdm_client/widgets/card/packet_card.dart'; import 'package:tsdm_client/widgets/card/poll_card.dart'; @@ -32,6 +35,10 @@ import 'package:universal_html/html.dart' as uh; import 'package:universal_html/parsing.dart'; import 'package:url_launcher/url_launcher.dart'; +/// Width of the secondary title, capped by available room without enlarging on rotation. +double postAuthorSecondBadgeWidth(double availableWidth) => + SecondaryTitleBadge.fitWidth(availableWidth, preferred: SecondaryTitleBadge.widthFor(authorBadgeHeight)); + /// Actions in post context menu. /// /// * State of [viewTheAuthor] and [viewAllAuthors] are thread level state so @@ -134,92 +141,140 @@ class _PostCardState extends State with AutomaticKeepAliveClientMixin, .toList() ?? []; - return ListTile( - leading: GestureDetector( - onTap: () async { - if (widget.post.userBriefProfile != null) { - await showUserBriefProfileDialog( - context, - widget.post.userBriefProfile!, - widget.post.author.url, - avatarHeroTag: avatarHeroTag, - nameHeroTag: nameHeroTag, - medals: medals, - badge: widget.post.badge, - secondBadge: widget.post.secondBadge, - signature: widget.post.signature, - pokemon: widget.post.pokemon, - checkin: widget.post.checkin, - ); - } - }, - child: HeroUserAvatar( - avatarUrl: widget.post.author.avatarUrl, - username: widget.post.author.name, - heroTag: avatarHeroTag, - ), - ), - title: Row( - crossAxisAlignment: CrossAxisAlignment.end, + Future openBriefProfile() async { + if (widget.post.userBriefProfile != null) { + await showUserBriefProfileDialog( + context, + widget.post.userBriefProfile!, + widget.post.author.url, + avatarHeroTag: avatarHeroTag, + nameHeroTag: nameHeroTag, + medals: medals, + badge: widget.post.badge, + secondBadge: widget.post.secondBadge, + signature: widget.post.signature, + pokemon: widget.post.pokemon, + checkin: widget.post.checkin, + ); + } + } + + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final nickname = widget.post.userBriefProfile?.nickname; + final userGroup = widget.post.userBriefProfile?.userGroup; + final publishTime = widget.post.publishTime; + final floor = widget.post.postFloor; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( - onTap: () async { - if (widget.post.userBriefProfile != null) { - await showUserBriefProfileDialog( - context, - widget.post.userBriefProfile!, - widget.post.author.url, - avatarHeroTag: avatarHeroTag, - nameHeroTag: nameHeroTag, - medals: medals, - badge: widget.post.badge, - secondBadge: widget.post.secondBadge, - signature: widget.post.signature, - pokemon: widget.post.pokemon, - checkin: widget.post.checkin, - ); - } - }, - child: Hero( - tag: nameHeroTag, - flightShuttleBuilder: (_, _, _, _, toHeroContext) => - DefaultTextStyle(style: DefaultTextStyle.of(toHeroContext).style, child: toHeroContext.widget), - child: Text(widget.post.author.name, style: TextStyle(color: Theme.of(context).colorScheme.primary)), + onTap: openBriefProfile, + child: SizedBox( + width: 44, + height: 44, + child: HeroUserAvatar( + avatarUrl: widget.post.author.avatarUrl, + username: widget.post.author.name, + heroTag: avatarHeroTag, + ), ), ), - sizedBoxW4H4, - if (widget.post.userBriefProfile?.nickname != null) - Expanded( + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + crossAxisAlignment: WrapCrossAlignment.end, + children: [ + GestureDetector( + onTap: openBriefProfile, + child: Hero( + tag: nameHeroTag, + flightShuttleBuilder: (_, _, _, _, toHeroContext) => DefaultTextStyle( + style: DefaultTextStyle.of(toHeroContext).style, + child: toHeroContext.widget, + ), + child: Text( + widget.post.author.name, + style: textTheme.titleSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + if (nickname != null) + Text( + nickname, + style: textTheme.labelSmall?.copyWith(color: colorScheme.secondary), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ), + sizedBoxW2H2, + Text( + [ + if (userGroup != null && userGroup.isNotEmpty) userGroup, + if (publishTime != null) publishTime.yyyyMMDDHHMMSS(), + ].join(' · '), + style: textTheme.labelSmall?.copyWith(color: colorScheme.outline), + ), + // Badges the forum renders in this floor's author column: the user group badge and the secondary title + // of this author. Nothing about the current account is used here. They wrap below each other on narrow + // windows instead of being squeezed, the secondary title keeps its 184:100 ratio. + if (widget.post.badge != null || widget.post.secondBadge != null) ...[ + sizedBoxW8H8, + LayoutBuilder( + builder: (context, constraints) => Wrap( + spacing: 10, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (widget.post.badge != null) + CachedImage( + widget.post.badge!, + height: authorBadgeHeight, + maxWidth: 160, + fit: BoxFit.contain, + ), + if (widget.post.secondBadge != null) + SecondaryTitleBadge( + widget.post.secondBadge!, + width: postAuthorSecondBadgeWidth(constraints.maxWidth), + ), + ], + ), + ), + ], + ], + ), + ), + sizedBoxW8H8, + DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), child: Text( - widget.post.userBriefProfile!.nickname!, - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.secondary), - overflow: TextOverflow.ellipsis, - maxLines: 1, + floor == null ? '#' : '#$floor', + style: textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), - ) - else - const Spacer(), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.post.userBriefProfile?.userGroup ?? '', - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), - ), - sizedBoxW4H4, - Text( - '${widget.post.publishTime?.yyyyMMDDHHMMSS()}', - style: Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.outline), + ), ), ], ), - trailing: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [if (widget.post.postFloor == null) const Text('#') else Text('#${widget.post.postFloor}')], - ), ); } @@ -286,129 +341,71 @@ class _PostCardState extends State with AutomaticKeepAliveClientMixin, List> _buildContextMenuEntries(BuildContext context) { final threadBloc = context.readOrNull(); final onlyVisibleUid = threadBloc?.state.onlyVisibleUid; + final colorScheme = Theme.of(context).colorScheme; - return [ - PopupMenuItem<_PostCardActions>( - enabled: false, - height: 32, - child: Text( - '${widget.post.author.name.truncate(10, ellipsis: true)} #${widget.post.postFloor}', - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), - ), - ), - PopupMenuItem( - value: _PostCardActions.reply, - child: Row( - children: [ - const Icon(Icons.reply_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.reply), - ], - ), - ), - if (widget.post.rateAction != null) - PopupMenuItem( - value: _PostCardActions.rate, - child: Row( - children: [ - const Icon(Icons.rate_review_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.rate), - ], - ), - ), - - /// Viewing all authors, can switch to only view current - /// author mode. - if (threadBloc != null && onlyVisibleUid == null && widget.post.author.uid != null) + PopupMenuItem<_PostCardActions> item(_PostCardActions value, IconData icon, String text, {Color? color}) => PopupMenuItem( - value: _PostCardActions.viewTheAuthor, + value: value, child: Row( children: [ - const Icon(Icons.person_outlined), + Icon(icon, color: color), sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.onlyViewAuthor), + Flexible( + child: Text(text, style: color == null ? null : TextStyle(color: color)), + ), ], ), - ), + ); - /// Viewing specified author now, can switch to view all - /// authors mode. - if (threadBloc != null && onlyVisibleUid != null) - PopupMenuItem( - value: _PostCardActions.viewAllAuthors, - child: Row( - children: [ - const Icon(Icons.group_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.viewAllAuthors), - ], - ), - ), - if (widget.post.editUrl != null) - PopupMenuItem( - value: _PostCardActions.edit, - child: Row( - children: [ - const Icon(Icons.edit_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.edit), - ], - ), - ), - if (widget.post.shareLink != null) ...[ - PopupMenuItem( - value: _PostCardActions.share, - child: Row( - children: [ - const Icon(Icons.share_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.share), - ], - ), - ), - PopupMenuItem( - value: _PostCardActions.openInBrowser, - child: Row( - children: [ - const Icon(Icons.open_in_browser_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.openInBrowser), - ], - ), - ), - PopupMenuItem( - value: _PostCardActions.openAndCopy, - child: Row( - children: [ - const Icon(Icons.copy_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.copyText), - ], - ), - ), - PopupMenuItem( - value: _PostCardActions.copyPid, - child: Row( - children: [ - const Icon(Icons.numbers_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postCard.copyPid(pid: widget.post.postID)), - ], + // Same actions and conditions as before, grouped: answer the floor, change what is shown (author filter, edit), + // share and copy, then report on its own in the error color. + final groups = >>[ + [ + item(_PostCardActions.reply, Icons.reply_outlined, context.t.postCard.reply), + if (widget.post.rateAction != null) + item(_PostCardActions.rate, Icons.rate_review_outlined, context.t.postCard.rate), + ], + [ + // Viewing all authors, can switch to only view current author mode. + if (threadBloc != null && onlyVisibleUid == null && widget.post.author.uid != null) + item(_PostCardActions.viewTheAuthor, Icons.person_outlined, context.t.postCard.onlyViewAuthor), + // Viewing specified author now, can switch to view all authors mode. + if (threadBloc != null && onlyVisibleUid != null) + item(_PostCardActions.viewAllAuthors, Icons.group_outlined, context.t.postCard.viewAllAuthors), + if (widget.post.editUrl != null) item(_PostCardActions.edit, Icons.edit_outlined, context.t.postCard.edit), + ], + [ + if (widget.post.shareLink != null) ...[ + item(_PostCardActions.share, Icons.share_outlined, context.t.postCard.share), + item(_PostCardActions.openInBrowser, Icons.open_in_browser_outlined, context.t.postCard.openInBrowser), + item(_PostCardActions.openAndCopy, Icons.copy_outlined, context.t.postCard.copyText), + item( + _PostCardActions.copyPid, + Icons.numbers_outlined, + context.t.postCard.copyPid(pid: widget.post.postID), ), - ), + ], ], - if (widget.post.reportTarget != null) - PopupMenuItem( - value: _PostCardActions.report, - child: Row( - children: [ - const Icon(Icons.flag_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.postReport.menu), - ], + [ + if (widget.post.reportTarget != null) + item(_PostCardActions.report, Icons.flag_outlined, context.t.postReport.menu, color: colorScheme.error), + ], + ]; + + return [ + // Which floor the menu acts on. + PopupMenuItem<_PostCardActions>( + enabled: false, + height: 32, + child: Text( + '${widget.post.author.name.truncate(10, ellipsis: true)} #${widget.post.postFloor}', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.secondary, + fontWeight: FontWeight.bold, ), ), + ), + for (final group in groups.where((e) => e.isNotEmpty)) ...[const PopupMenuDivider(height: 8), ...group], ]; } @@ -489,9 +486,15 @@ class _PostCardState extends State with AutomaticKeepAliveClientMixin, Widget _buildContextMenuRow(BuildContext context) => Row( children: [ const Spacer(), + // The floor's action menu, at the end of the floor: muted rounded button, the icon stays the usual "more" so it + // reads like the other menus of the app. PopupMenuButton( itemBuilder: _buildContextMenuEntries, onSelected: (value) async => _onContextMenuItemSelected(context, value), + style: IconButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(appInnerRadius)), + ), ), sizedBoxW8H8, ], diff --git a/lib/widgets/card/post_card/show_user_brief_profile_dialog.dart b/lib/widgets/card/post_card/show_user_brief_profile_dialog.dart index 1dba28ebd..70b88928a 100644 --- a/lib/widgets/card/post_card/show_user_brief_profile_dialog.dart +++ b/lib/widgets/card/post_card/show_user_brief_profile_dialog.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; @@ -6,12 +8,15 @@ import 'package:tsdm_client/constants/constants.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/medal.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/html/adaptive_color.dart'; import 'package:tsdm_client/utils/html/html_muncher.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/bubble.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart'; @@ -22,6 +27,14 @@ import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/medal_group_view.dart'; import 'package:universal_html/parsing.dart'; +/// Width available to the content of the brief profile dialog in a window [windowWidth] wide. +/// +/// Mirrors the constraint of [CustomAlertDialog] (70% of the window, at most 400) without its 24px side paddings. +double briefProfileDialogContentWidth(double windowWidth) => math.min(windowWidth * 0.7, 400) - 48; + +/// Height the stacked dialog header needs: the avatar row, the name and the pills under it. +const _stackedHeaderHeight = 132.0; + /// Show a dialog to display user brief profile. /// /// Data only available in thread page on all replied users. @@ -108,247 +121,368 @@ class _UserBriefProfileDialogState extends State<_UserBriefProfileDialog> { final textTheme = Theme.of(context).textTheme; final primaryColor = colorScheme.primary; - final sectionTitleStyle = textTheme.titleSmall?.copyWith(color: primaryColor); final pokemon = widget.pokemon; final checkin = widget.checkin; - const sectionSeparator = sizedBoxW24H24; - const titleContentSeparator = sizedBoxW8H8; + const sectionSeparator = sizedBoxW16H16; + const titleContentSeparator = sizedBoxW4H4; + Widget sectionTitle(String title, IconData icon) => + AppSectionHeader(title, icon: icon, padding: const EdgeInsets.symmetric(vertical: 4)); final inDarkTheme = Theme.of(context).brightness == Brightness.dark; final colorOffset = inDarkTheme ? 300 : 700; + // Measured like the badges below: the dialog content has no layout builder (the dialog sizes to intrinsics). + final contentWidth = briefProfileDialogContentWidth(MediaQuery.sizeOf(context).width); + final statWidth = (contentWidth - 8) / 2; + final secondBadgeWidth = SecondaryTitleBadge.fitWidth( + contentWidth, + preferred: SecondaryTitleBadge.widthFor(profileBadgeHeight), + ); + final authorUid = int.tryParse(widget.profile.uid); + // Only a verified current account counts; the uid is compared again by the badge against the title it holds. + final ownFloor = + widget.secondBadge == null && + authorUid != null && + authorUid == context.readOrNull()?.currentUser?.uid; - final titleContent = [ - Row( - children: [ - Hero( - tag: widget.avatarHeroTag, - child: CircleAvatar( - radius: 30, - backgroundImage: CachedImageProvider( - widget.profile.avatarUrl ?? noAvatarUrl, - usage: ImageUsageInfoUserAvatar(widget.profile.username), - ), - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.email_outlined), - tooltip: tr.pmTooltip, - onPressed: () => context.pushNamed( - ScreenPaths.chat, - pathParameters: {'uid': widget.profile.uid}, - extra: {'username': widget.profile.username}, - ), - ), - IconButton( - icon: const Icon(Icons.person_outlined), - tooltip: tr.profileTooltip, - onPressed: () async => context.dispatchAsUrl(widget.userSpaceUrl), - ), - ], - ), - sizedBoxW12H12, - - // Fix text style lost. - // ref: https://github.com/flutter/flutter/issues/30647#issuecomment-480980280 - Hero( - tag: widget.nameHeroTag, - flightShuttleBuilder: (_, _, _, _, toHeroContext) => - DefaultTextStyle(style: DefaultTextStyle.of(toHeroContext).style, child: toHeroContext.widget), - child: Text(widget.profile.username, style: textTheme.titleLarge?.copyWith(color: primaryColor)), + final avatar = Hero( + tag: widget.avatarHeroTag, + child: CircleAvatar( + radius: 30, + backgroundImage: CachedImageProvider( + widget.profile.avatarUrl ?? noAvatarUrl, + usage: ImageUsageInfoUserAvatar(widget.profile.username), + ), ), - sizedBoxW4H4, - Text('UID ${widget.profile.uid}', style: textTheme.labelMedium?.copyWith(color: colorScheme.outline)), - ]; + ); - final profileContent = [ - // Basic user info. - Text(tr.tabName.info, style: sectionTitleStyle), - titleContentSeparator, - _UserProfilePair( - Icons.group_outlined, - tr.group, - widget.profile.userGroup, - inDarkTheme ? widget.profile.userGroupColor?.adaptiveDark() : widget.profile.userGroupColor, - ), - if (widget.profile.title != null) - _UserProfilePair(Icons.badge_outlined, tr.title, widget.profile.title, Colors.blue[colorOffset]), - _UserProfilePair(MdiIcons.idCard, tr.nickname, widget.profile.nickname, Colors.blue[colorOffset]), - _UserProfilePair(Icons.thumb_up_outlined, tr.recommended, widget.profile.recommended, Colors.red[colorOffset]), - _UserProfilePair(Icons.book_outlined, tr.thread, widget.profile.threadCount, Colors.green[colorOffset]), - _UserProfilePair(MdiIcons.commentEditOutline, tr.post, widget.profile.postCount, Colors.cyan[colorOffset]), - _UserProfilePair(Icons.emoji_people_outlined, tr.famous, widget.profile.famous, Colors.purple[colorOffset]), - _UserProfilePair(FontAwesomeIcons.coins, tr.coins, widget.profile.coins, Colors.purple[colorOffset]), - _UserProfilePair(Icons.campaign_outlined, tr.publicity, widget.profile.publicity, Colors.purple[colorOffset]), - _UserProfilePair(Icons.water_drop_outlined, tr.natural, widget.profile.natural, Colors.purple[colorOffset]), - _UserProfilePair(MdiIcons.dominoMask, tr.scheming, widget.profile.scheming, Colors.purple[colorOffset]), - _UserProfilePair(Icons.stream_outlined, tr.spirit, widget.profile.spirit, Colors.purple[colorOffset]), - // Special attr, dynamic and not translated. - _UserProfilePair( - MdiIcons.heartOutline, - widget.profile.specialAttrName, - widget.profile.specialAttr, - Colors.purple[colorOffset], - ), - // Optional special attr, dynamic and not tranlsated. - if (widget.profile.specialAttr2 != null && widget.profile.specialAttrName2 != null) - _UserProfilePair( - MdiIcons.heartOutline, - widget.profile.specialAttrName2!, - widget.profile.specialAttr2, - Colors.purple[colorOffset], + // Fix text style lost. + // ref: https://github.com/flutter/flutter/issues/30647#issuecomment-480980280 + final name = Hero( + tag: widget.nameHeroTag, + flightShuttleBuilder: (_, _, _, _, toHeroContext) => + DefaultTextStyle(style: DefaultTextStyle.of(toHeroContext).style, child: toHeroContext.widget), + child: Text(widget.profile.username, style: textTheme.titleLarge?.copyWith(color: primaryColor)), + ); + + // Uid and online state as small pills next to the name. + final pills = Wrap( + spacing: 6, + runSpacing: 4, + children: [ + AppInfoPill(icon: Icons.tag, label: 'UID ${widget.profile.uid}'), + AppInfoPill( + icon: widget.profile.online ? Icons.circle : Icons.circle_outlined, + label: widget.profile.online ? tr.status.online : tr.status.offline, + tooltip: tr.status.title, + ), + ], + ); + + final headerActions = [ + IconButton.filledTonal( + icon: const Icon(Icons.email_outlined), + tooltip: tr.pmTooltip, + onPressed: () => context.pushNamed( + ScreenPaths.chat, + pathParameters: {'uid': widget.profile.uid}, + extra: {'username': widget.profile.username}, ), - if (widget.profile.couple != null && widget.profile.couple!.isNotEmpty) - _UserProfilePair(Icons.diversity_1_outlined, tr.cp, widget.profile.couple, Colors.pink[colorOffset]), - _UserProfilePair(Icons.feedback_outlined, tr.privilege, widget.profile.privilege, Colors.orange[colorOffset]), - _UserProfilePair( - Icons.event_note_outlined, - tr.registration, - widget.profile.registrationDate, - Colors.blue[colorOffset], ), - if (widget.profile.comeFrom != null) - _UserProfilePair(Icons.pin_drop_outlined, tr.from, widget.profile.comeFrom, Colors.teal[colorOffset]), - _UserProfilePair( - Icons.online_prediction_outlined, - tr.status.title, - widget.profile.online ? tr.status.online : tr.status.offline, - widget.profile.online ? Colors.green[colorOffset] : Colors.grey, + IconButton.filledTonal( + icon: const Icon(Icons.person_outlined), + tooltip: tr.profileTooltip, + onPressed: () async => context.dispatchAsUrl(widget.userSpaceUrl), ), + ]; - sectionSeparator, + // The title of an AlertDialog is not flexible and CustomAlertDialog keeps it within 30% of the window height, so + // stacking the avatar, the name and the pills cut the pills off on a phone in landscape. One row fits there. + final titleContent = MediaQuery.sizeOf(context).height * 0.3 >= _stackedHeaderHeight + ? [ + Row(children: [avatar, const Spacer(), ...headerActions]), + sizedBoxW12H12, + name, + sizedBoxW4H4, + pills, + ] + : [ + Row( + children: [ + avatar, + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [name, sizedBoxW4H4, pills], + ), + ), + ...headerActions, + ], + ), + ]; - // Badge - Text(tr.badges, style: sectionTitleStyle), - titleContentSeparator, - // Both the primary the secondary badge presents. - if (widget.badge != null && widget.secondBadge != null) - Row( + // Numbers of the author, two per row: value first (in its color), name under it. + Widget stat(IconData icon, String name, String? value, Color? color) => SizedBox( + width: statWidth, + child: AppInsetBlock( + padding: const EdgeInsets.fromLTRB(10, 8, 8, 8), + child: Row( children: [ + Icon(icon, size: 18, color: color ?? colorScheme.secondary), + sizedBoxW8H8, Expanded( - child: CachedImage(widget.badge!, width: badgeImageSize.width, height: badgeImageSize.height), - ), - Expanded( - child: CachedImage(widget.secondBadge!, width: badgeImageSize.width, height: badgeImageSize.height), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value ?? '-', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(color: color, fontWeight: FontWeight.bold), + ), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.labelSmall?.copyWith(color: colorScheme.outline), + ), + ], + ), ), ], - ) - // Only the primary badge. - else if (widget.badge != null) - Row( + ), + ), + ); + + final profileContent = [ + // Who the author is: group, titles, names and dates, one line each. + sectionTitle(tr.tabName.info, Icons.badge_outlined), + titleContentSeparator, + AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, children: [ - Expanded( - child: CachedImage(widget.badge!, width: badgeImageSize.width, height: badgeImageSize.height), + _UserProfilePair( + Icons.group_outlined, + tr.group, + widget.profile.userGroup, + inDarkTheme ? widget.profile.userGroupColor?.adaptiveDark() : widget.profile.userGroupColor, ), - const Spacer(), - ], - ) - // Only the secondary badge. - else if (widget.secondBadge != null) - Row( - children: [ - Expanded( - child: CachedImage(widget.secondBadge!, width: badgeImageSize.width, height: badgeImageSize.height), + if (widget.profile.title != null) + _UserProfilePair(Icons.badge_outlined, tr.title, widget.profile.title, Colors.blue[colorOffset]), + _UserProfilePair(MdiIcons.idCard, tr.nickname, widget.profile.nickname, Colors.blue[colorOffset]), + if (widget.profile.couple != null && widget.profile.couple!.isNotEmpty) + _UserProfilePair(Icons.diversity_1_outlined, tr.cp, widget.profile.couple, Colors.pink[colorOffset]), + _UserProfilePair( + Icons.feedback_outlined, + tr.privilege, + widget.profile.privilege, + Colors.orange[colorOffset], ), - const Spacer(), + _UserProfilePair( + Icons.event_note_outlined, + tr.registration, + widget.profile.registrationDate, + Colors.blue[colorOffset], + ), + if (widget.profile.comeFrom != null) + _UserProfilePair(Icons.pin_drop_outlined, tr.from, widget.profile.comeFrom, Colors.teal[colorOffset]), + // The online state is the pill under the name. ], - ) - // No badges at all, theoretically unreachable. - else - sizedBoxEmpty, + ), + ), + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + stat(Icons.thumb_up_outlined, tr.recommended, widget.profile.recommended, Colors.red[colorOffset]), + stat(Icons.book_outlined, tr.thread, widget.profile.threadCount, Colors.green[colorOffset]), + stat(MdiIcons.commentEditOutline, tr.post, widget.profile.postCount, Colors.cyan[colorOffset]), + stat(Icons.emoji_people_outlined, tr.famous, widget.profile.famous, Colors.purple[colorOffset]), + stat(FontAwesomeIcons.coins, tr.coins, widget.profile.coins, Colors.purple[colorOffset]), + stat(Icons.campaign_outlined, tr.publicity, widget.profile.publicity, Colors.purple[colorOffset]), + stat(Icons.water_drop_outlined, tr.natural, widget.profile.natural, Colors.purple[colorOffset]), + stat(MdiIcons.dominoMask, tr.scheming, widget.profile.scheming, Colors.purple[colorOffset]), + stat(Icons.stream_outlined, tr.spirit, widget.profile.spirit, Colors.purple[colorOffset]), + // Special attr, dynamic and not translated. + stat( + MdiIcons.heartOutline, + widget.profile.specialAttrName, + widget.profile.specialAttr, + Colors.purple[colorOffset], + ), + // Optional special attr, dynamic and not tranlsated. + if (widget.profile.specialAttr2 != null && widget.profile.specialAttrName2 != null) + stat( + MdiIcons.heartOutline, + widget.profile.specialAttrName2!, + widget.profile.specialAttr2, + Colors.purple[colorOffset], + ), + ], + ), sectionSeparator, + // Badges from this author's own floor: the user group badge and the secondary title. Both keep their natural + // aspect ratio and share a display height; narrow dialogs wrap instead of squeezing either image. + // + // When the floor carries no secondary title and the author is the current account, the title that account + // uses is shown instead (CurrentTitleCubit: read-only title page of that account, keyed by uid and dropped on + // account changes). The floor of anybody else never gets it: no data there means no second badge. + if (widget.badge != null || widget.secondBadge != null || ownFloor) ...[ + sectionTitle(tr.badges, Icons.military_tech_outlined), + titleContentSeparator, + Wrap( + spacing: 16, + runSpacing: 12, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (widget.badge != null) + CachedImage( + widget.badge!, + key: const ValueKey('brief-profile-group-badge'), + maxWidth: contentWidth, + height: profileBadgeHeight, + fit: BoxFit.contain, + ), + if (widget.secondBadge != null) + SecondaryTitleBadge( + widget.secondBadge!, + key: const ValueKey('brief-profile-floor-title'), + width: secondBadgeWidth, + ) + else if (ownFloor) + CurrentAccountTitleBadge( + key: const ValueKey('brief-profile-account-title'), + uid: authorUid, + width: secondBadgeWidth, + ), + ], + ), + sectionSeparator, + ], + // Medal if (widget.medals.isNotEmpty) ...[ - Text(tr.medals, style: sectionTitleStyle), + sectionTitle(tr.medals, Icons.workspace_premium_outlined), titleContentSeparator, MedalGroupView(widget.medals), sectionSeparator, ], - // Signature, if any. Size is unpredicted. + // Signature, if any. Size is unpredicted; the dialog body scrolls, so it is laid out at its natural height. if (widget.signature != null) ...[ - Text(tr.tabName.signature, style: sectionTitleStyle), + sectionTitle(tr.tabName.signature, Icons.draw_outlined), titleContentSeparator, - // Wrap in Flexible because munchElement returns unbounded widget size. - Flexible(child: munchElement(context, parseHtmlDocument(widget.signature!).body!)), + AppInsetBlock(child: munchElement(context, parseHtmlDocument(widget.signature!).body!)), sectionSeparator, ], - // Pokemon + // Pokemon: the primary one large, the others as small chips. if (pokemon != null) ...[ - Text(tr.pokemon, style: sectionTitleStyle), + sectionTitle(tr.pokemon, Icons.catching_pokemon), titleContentSeparator, - CachedImage( - pokemon.primaryPokemon.image, - width: pokemonPrimaryImageSize.width, - height: pokemonPrimaryImageSize.height, - ), - Text(pokemon.primaryPokemon.name, style: textTheme.titleSmall?.copyWith(color: colorScheme.secondary)), - if (pokemon.otherPokemon != null) - Column( - children: pokemon.otherPokemon! - .map( - (e) => Row( - children: [ - CachedImage( - e.image, - width: pokemonNotPrimaryImageSize.width, - height: pokemonNotPrimaryImageSize.height, - ), - sizedBoxW4H4, - Text(e.name), - ], + AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CachedImage( + pokemon.primaryPokemon.image, + width: pokemonPrimaryImageSize.width, + height: pokemonPrimaryImageSize.height, + ), + sizedBoxW8H8, + Expanded( + child: Text( + pokemon.primaryPokemon.name, + style: textTheme.titleSmall?.copyWith(color: colorScheme.secondary, fontWeight: FontWeight.bold), + ), ), - ) - .toList(), + ], + ), + if (pokemon.otherPokemon != null && pokemon.otherPokemon!.isNotEmpty) ...[ + sizedBoxW8H8, + Wrap( + spacing: 8, + runSpacing: 4, + children: pokemon.otherPokemon! + .map( + (e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + CachedImage( + e.image, + width: pokemonNotPrimaryImageSize.width, + height: pokemonNotPrimaryImageSize.height, + ), + sizedBoxW4H4, + Text(e.name, style: textTheme.bodySmall), + ], + ), + ) + .toList(), + ), + ], + ], ), + ), sectionSeparator, ], - // Checkin status. + // Checkin status: feeling on the start side, the words in a bubble, statistics under them. if (checkin != null) ...[ - Text(tr.checkin, style: sectionTitleStyle), + sectionTitle(tr.checkin, Icons.event_available_outlined), titleContentSeparator, - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - CachedImage(checkin.feelingImage, width: feelingImageSize.width, height: feelingImageSize.height), - sizedBoxW4H4, - Text( - checkin.feelingName, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.secondary), - ), - ], - ), - sizedBoxW8H8, - Flexible( - child: CustomPaint( - painter: BubblePainter( - color: Theme.of(context).colorScheme.primaryContainer, - alignment: Alignment.topLeft, - tail: true, - ), - child: Container( - margin: const EdgeInsets.fromLTRB(14, 7, 7, 7), - child: Text( - checkin.words, - style: textTheme.bodyMedium?.copyWith(color: colorScheme.onPrimaryContainer), + AppInsetBlock( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + CachedImage( + checkin.feelingImage, + width: feelingImageSize.width, + height: feelingImageSize.height, + ), + sizedBoxW4H4, + Text(checkin.feelingName, style: textTheme.bodyMedium?.copyWith(color: colorScheme.secondary)), + ], ), - ), + sizedBoxW8H8, + Flexible( + child: CustomPaint( + painter: BubblePainter( + color: colorScheme.primaryContainer, + alignment: Alignment.topLeft, + tail: true, + ), + child: Container( + margin: const EdgeInsets.fromLTRB(14, 7, 7, 7), + child: Text( + checkin.words, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onPrimaryContainer), + ), + ), + ), + ), + ], ), - ), - ], + sizedBoxW8H8, + // Checkin statistics info. + Text(checkin.statistics, style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + ], + ), ), - sizedBoxW4H4, - // Checkin statistics info. - Text(checkin.statistics), ], ]; @@ -379,14 +513,17 @@ class _UserProfilePair extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // Name in a muted label, value in its own color; long values wrap under themselves. return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(iconData, size: 18, color: colorScheme.secondary), - sizedBoxW4H4, - Text(name, style: TextStyle(color: colorScheme.secondary)), + Icon(iconData, size: 16, color: colorScheme.outline), + sizedBoxW8H8, + Text(name, style: textTheme.bodySmall?.copyWith(color: colorScheme.outline)), sizedBoxW12H12, Expanded( - child: Text(value ?? '', style: TextStyle(color: valueColor)), + child: Text(value ?? '', style: textTheme.bodyMedium?.copyWith(color: valueColor)), ), ], ); diff --git a/lib/widgets/card/rate_card.dart b/lib/widgets/card/rate_card.dart index 0175ba307..ef5197fdb 100644 --- a/lib/widgets/card/rate_card.dart +++ b/lib/widgets/card/rate_card.dart @@ -8,6 +8,7 @@ import 'package:tsdm_client/features/thread/v1/bloc/thread_bloc.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart'; /// Widget to show the rate statistics for a post. @@ -23,8 +24,8 @@ class RateCard extends StatelessWidget { @override Widget build(BuildContext context) { - final primaryColor = Theme.of(context).colorScheme.primary; - final secondaryColor = Theme.of(context).colorScheme.secondary; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; final threadInfo = context.readOrNull()?.state; final tid = threadInfo?.tid; @@ -42,110 +43,108 @@ class RateCard extends StatelessWidget { columnWidths[rate.attrList.length + 1] = const FixedColumnWidth(200); // , + // Header cells: small bold labels in the outline color. final tableHeaders = [context.t.rateCard.user, ...rate.attrList] .map( - (e) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(e, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: secondaryColor)), - sizedBoxW8H8, - ], + (e) => Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), + child: Text( + e, + style: textTheme.labelMedium?.copyWith(color: colorScheme.outline, fontWeight: FontWeight.bold), + ), ), ) .toList(); - final bottom = Text( - context.t.rateCard.total(total: rate.rateStatus ?? '-'), - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: secondaryColor), - ); + // Score cells keep the forum's text; only the sign picks the color (display only). + Color scoreColor(String value) { + final v = value.trim(); + if (v.startsWith('-')) { + return colorScheme.error; + } + return v.isEmpty || v == '0' ? colorScheme.outline : colorScheme.primary; + } final tableContent = rate.records .mapIndexed( (idx, e) => TableRow( - decoration: BoxDecoration(color: idx.isOdd ? Theme.of(context).colorScheme.surfaceContainerHigh : null), + decoration: BoxDecoration(color: idx.isEven ? colorScheme.surfaceContainerHigh : null), children: [ - Row( - children: [ - SizedBox( - height: 50, - child: Center( + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + children: [ + GestureDetector( + onTap: () async => context.dispatchAsUrl(e.user.url), + // TODO: Add hero here. + child: CircleAvatar( + radius: 16, + backgroundImage: CachedImageProvider( + e.user.avatarUrl ?? noAvatarUrl, + fallbackImageUrl: noAvatarUrl, + usage: ImageUsageInfoUserAvatar(e.user.name), + ), + ), + ), + sizedBoxW8H8, + Expanded( child: GestureDetector( onTap: () async => context.dispatchAsUrl(e.user.url), - // TODO: Add hero here. - child: CircleAvatar( - backgroundImage: CachedImageProvider( - e.user.avatarUrl ?? noAvatarUrl, - fallbackImageUrl: noAvatarUrl, - usage: ImageUsageInfoUserAvatar(e.user.name), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + e.user.name, + textAlign: TextAlign.left, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium, ), ), ), ), - ), - sizedBoxW8H8, - Expanded( - child: GestureDetector( - onTap: () async => context.dispatchAsUrl(e.user.url), - child: Align( - alignment: Alignment.centerLeft, - child: Text(e.user.name, textAlign: TextAlign.left), - ), - ), - ), - ], + ], + ), + ), + ...e.attrValueList.map( + (e) => Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 12, 6), + child: Text(e, style: textTheme.bodyMedium?.copyWith(color: scoreColor(e))), + ), ), - ...e.attrValueList.map((e) => Row(mainAxisSize: MainAxisSize.min, children: [Text(e), sizedBoxW8H8])), ], ), ) .toList(); - return Card( - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.rate_review_outlined, color: primaryColor), - sizedBoxW8H8, - Text( - context.t.rateCard.title(userCount: '${rate.userCount}'), - style: Theme.of(context).textTheme.titleMedium?.copyWith(color: primaryColor), - ), - sizedBoxW12H12, - IconButton( - icon: Icon(Icons.bar_chart_outlined, color: primaryColor), - tooltip: context.t.rateCard.viewAll, - onPressed: tid == null - ? null - : () async => context.pushNamed( - ScreenPaths.rateLog, - pathParameters: {'tid': tid, 'pid': pid}, - queryParameters: {'threadTitle': threadTitle, 'total': rate.rateStatus}, - ), - ), - ], - ), - sizedBoxW12H12, - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - columnWidths: columnWidths, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: [ - TableRow(children: tableHeaders), - ...tableContent, - ], + return AppEmbedCard( + icon: Icons.rate_review_outlined, + title: context.t.rateCard.title(userCount: '${rate.userCount}'), + subtitle: context.t.rateCard.total(total: rate.rateStatus ?? '-'), + trailing: IconButton( + icon: Icon(Icons.bar_chart_outlined, color: colorScheme.primary), + tooltip: context.t.rateCard.viewAll, + onPressed: tid == null + ? null + : () async => context.pushNamed( + ScreenPaths.rateLog, + pathParameters: {'tid': tid, 'pid': pid}, + queryParameters: {'threadTitle': threadTitle, 'total': rate.rateStatus}, ), - ), - sizedBoxW12H12, - bottom, - ], + ), + // The table scrolls sideways inside its own rounded block when there are many attributes. + child: ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + columnWidths: columnWidths, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [ + TableRow(children: tableHeaders), + ...tableContent, + ], + ), ), ), ); diff --git a/lib/widgets/card/review_card.dart b/lib/widgets/card/review_card.dart index c62d34224..db9714411 100644 --- a/lib/widgets/card/review_card.dart +++ b/lib/widgets/card/review_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart'; /// Widget to show a review for a post. @@ -20,23 +21,40 @@ class ReviewCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Divider(), - Text(context.t.reviewCard.title, style: Theme.of(context).textTheme.titleMedium), - ListTile( - leading: CircleAvatar( + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + // A short review under the floor: reviewer and their words in one rounded block. + return AppEmbedCard( + icon: Icons.reviews_outlined, + title: context.t.reviewCard.title, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 16, backgroundImage: CachedImageProvider( avatarUrl ?? noAvatarUrl, fallbackImageUrl: noAvatarUrl, usage: ImageUsageInfoUserAvatar(name), ), ), - title: Text(name), - subtitle: Text(content, maxLines: 3), - ), - ], + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)), + Text( + content, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], + ), + ), + ], + ), ); } } diff --git a/lib/widgets/card/spoiler_card.dart b/lib/widgets/card/spoiler_card.dart index 5bd77db53..2b75c5769 100644 --- a/lib/widgets/card/spoiler_card.dart +++ b/lib/widgets/card/spoiler_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/list.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// SpoilerCard is an area munched from html document. /// @@ -31,36 +32,26 @@ class _SpoilerCardState extends State { @override Widget build(BuildContext context) { final tr = context.t.spoilerCard; - final primaryColor = Theme.of(context).colorScheme.primary; - return Card( - elevation: widget.elevation, - child: Padding( - padding: edgeInsetsL16T16R16B16, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.expand_outlined, color: primaryColor), - sizedBoxW8H8, - Text(tr.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(color: primaryColor)), - ], - ), - sizedBoxW8H8, - OutlinedButton.icon( - icon: _visible ? const Icon(Icons.expand_less_outlined) : const Icon(Icons.expand_more_outlined), - label: Text.rich(widget.title), - onPressed: () { - setState(() { - _visible = !_visible; - }); - }, - ), - if (_visible) ...[sizedBoxW8H8, Text.rich(widget.content)], - ].insertBetween(sizedBoxW4H4), - ), + // The toggle carries the forum's own spoiler title; the content opens under it in the same block. + return AppEmbedCard( + icon: Icons.expand_outlined, + title: tr.title, + color: appEmbedColor(context, widget.elevation), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + icon: _visible ? const Icon(Icons.expand_less_outlined) : const Icon(Icons.expand_more_outlined), + label: Text.rich(widget.title), + onPressed: () { + setState(() { + _visible = !_visible; + }); + }, + ), + if (_visible) ...[sizedBoxW8H8, Text.rich(widget.content)], + ].insertBetween(sizedBoxW4H4), ), ); } diff --git a/lib/widgets/card/thread_card/thread_card.dart b/lib/widgets/card/thread_card/thread_card.dart index 3abc126ad..18c2c2ff6 100644 --- a/lib/widgets/card/thread_card/thread_card.dart +++ b/lib/widgets/card/thread_card/thread_card.dart @@ -23,6 +23,7 @@ import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/themes/widget_themes.dart'; import 'package:tsdm_client/widgets/adaptive_ink_response.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/heroes.dart'; import 'package:tsdm_client/widgets/quoted_text.dart'; import 'package:tsdm_client/widgets/single_line_text.dart'; @@ -266,52 +267,91 @@ class _CardLayout extends StatelessWidget { fontSize: Theme.of(context).textTheme.labelMedium?.fontSize, ); + final textTheme = Theme.of(context).textTheme; + final nameStyle = textTheme.titleSmall?.merge(authorNameStyle); + // Marks at the end of the head: replied by the current account, thread states, thread type (or forum) tag. + final marks = [ + if (replied) _buildRepliedMark(context), + if (stateSet != null) ...stateSet!.map((e) => Icon(e.icon, size: 16)), + if (threadType?.name.isNotEmpty ?? false) _buildTypeTag(context, threadType!.name), + ]; + + // Head of the card: who (avatar and name, both open the user) or, without an author, the forum; then when and + // where on one wrapping line; the marks stay at the end. Replaces a ListTile whose fixed three line layout cut + // the forum name with a large font. The settings of the card (highlighted name, recent time) apply as before. + final Widget leading; + final Widget name; if (author != null) { - return ListTile( - leading: GestureDetector( - onTap: disableTap ? null : () async => context.dispatchAsUrl(author!.url), - child: _buildAvatar(context), - ), - title: Row( - children: [ - GestureDetector( - onTap: disableTap ? null : () async => context.dispatchAsUrl(author!.url), - child: SingleLineText(author!.name, style: authorNameStyle), - ), - const Spacer(), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (publishTime != null) SingleLineText(publishTime!.yyyyMMDD(), style: timeStyle), - if (forum != null) SingleLineText(forum!, style: forumNameStyle), - ], - ), - trailing: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - if (replied) _buildRepliedMark(context), - if (stateSet != null) ...stateSet!.map((e) => Icon(e.icon, size: 16)), - Text(threadType?.name ?? ''), - ].insertBetween(sizedBoxW4H4), + Future openAuthor() async => context.dispatchAsUrl(author!.url); + leading = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: disableTap ? null : openAuthor, + child: _buildAvatar(context), + ); + name = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: disableTap ? null : openAuthor, + child: SingleLineText(author!.name, style: nameStyle), + ); + } else { + final colorScheme = Theme.of(context).colorScheme; + leading = Tooltip( + message: context.t.myThreadPage.forum, + child: AppIconTile( + Icons.forum_outlined, + color: colorScheme.secondaryContainer, + foregroundColor: colorScheme.onSecondaryContainer, ), ); + name = SingleLineText('$forum', style: nameStyle); } + final meta = [ + if (publishTime != null) SingleLineText(publishTime!.yyyyMMDD(), style: timeStyle), + // The forum is the title of the head already when there is no author. + if (author != null && forum != null) SingleLineText(forum!, style: forumNameStyle), + ]; - return ListTile( - leading: Chip(label: Text(context.t.myThreadPage.forum)), - title: Text('$forum', style: authorNameStyle), - subtitle: publishTime != null ? SingleLineText(publishTime!.yyyyMMDD(), style: timeStyle) : null, - trailing: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - if (replied) _buildRepliedMark(context), - if (stateSet != null) ...stateSet!.map((e) => Icon(e.icon, size: 16)), - Text(threadType?.name ?? ''), - ].insertBetween(sizedBoxW4H4), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 4), + child: Row( + children: [ + leading, + sizedBoxW12H12, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + name, + if (meta.isNotEmpty) Wrap(spacing: 8, runSpacing: 2, children: meta), + ], + ), + ), + if (marks.isNotEmpty) ...[ + sizedBoxW8H8, + Row(mainAxisSize: MainAxisSize.min, children: marks.insertBetween(sizedBoxW4H4)), + ], + ], + ), + ); + } + + /// Thread type (or forum, for the latest threads) as a small tag, like the forum tags of the homepage guide. + Widget _buildTypeTag(BuildContext context, String name) { + final colorScheme = Theme.of(context).colorScheme; + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 140), + child: DecoratedBox( + decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(6)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith(color: colorScheme.onSecondaryContainer), + ), + ), ), ); } @@ -342,11 +382,16 @@ class _CardLayout extends StatelessWidget { child: Row( children: [ Expanded( + // The thread title leads the card, as in the homepage guide; forum set colors and weights stay. child: Text( title, maxLines: 3, overflow: TextOverflow.ellipsis, - style: TextStyle(color: css?.color, fontWeight: css?.fontWeight), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: css?.color, + fontWeight: css?.fontWeight ?? FontWeight.w500, + height: 1.45, + ), ), ), ], @@ -363,7 +408,7 @@ class _CardLayout extends StatelessWidget { showLastReplyAuthor: showLastReplyAuthor, highlightInfoRow: highlightInfoRow, ), - ].insertBetween(sizedBoxW12H12), + ].insertBetween(sizedBoxW4H4), ), ); } diff --git a/lib/widgets/copy_content_dialog.dart b/lib/widgets/copy_content_dialog.dart index 57dfc814e..75fbecf1d 100644 --- a/lib/widgets/copy_content_dialog.dart +++ b/lib/widgets/copy_content_dialog.dart @@ -9,6 +9,7 @@ import 'package:tsdm_client/features/root/view/root_page.dart'; import 'package:tsdm_client/i18n/strings.g.dart'; import 'package:tsdm_client/routes/screen_paths.dart'; import 'package:tsdm_client/utils/clipboard.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/copy_button.dart'; import 'package:tsdm_client/widgets/custom_alert_dialog.dart'; import 'package:tsdm_client/widgets/indicator.dart'; @@ -121,21 +122,44 @@ class _CopyContentDialogState extends State<_CopyContentDialog> { Widget build(BuildContext context) { final tr = context.t.copyDialog; + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; return CustomAlertDialog.sync( - title: Text(widget.title ?? tr.copyTitle), + title: Row( + children: [ + const AppIconTile(Icons.content_copy_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(widget.title ?? tr.copyTitle)), + ], + ), content: Padding( - padding: edgeInsetsR12.add(edgeInsetsT12), + padding: edgeInsetsT12, child: Column( - spacing: 12, - crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + // One block per value: its name, the value itself (selectable, wraps) and a copy button beside it. children: widget.contents .mapIndexed( - (idx, e) => TextField( - controller: controllers[idx], - readOnly: true, - decoration: InputDecoration( - labelText: e.name, - suffixIcon: CopyButton(data: controllers[idx].text), + (idx, e) => AppInsetBlock( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(e.name, style: textTheme.labelMedium?.copyWith(color: colorScheme.outline)), + sizedBoxW2H2, + SelectableText( + controllers[idx].text, + maxLines: 4, + style: textTheme.bodyMedium, + ), + ], + ), + ), + CopyButton(data: controllers[idx].text), + ], ), ), ) @@ -180,11 +204,18 @@ class _CopySelectContentDialog extends StatelessWidget { final tr = context.t.copyDialog; return CustomAlertDialog.sync( - title: Text(title ?? tr.copySelectTitle), - content: SelectableText(data), + title: Row( + children: [ + const AppIconTile(Icons.text_snippet_outlined, size: 36), + sizedBoxW12H12, + Expanded(child: Text(title ?? tr.copySelectTitle)), + ], + ), + content: AppInsetBlock(padding: edgeInsetsL12T12R12B12, child: SelectableText(data)), actions: [ - TextButton( - child: Text(tr.share), + TextButton.icon( + icon: const Icon(Icons.share_outlined), + label: Text(tr.share), onPressed: () async { await SharePlus.instance.share(ShareParams(text: data)); if (!context.mounted) { @@ -193,8 +224,9 @@ class _CopySelectContentDialog extends StatelessWidget { context.pop(); }, ), - TextButton( - child: Text(tr.copyAll), + FilledButton.icon( + icon: const Icon(Icons.copy_all_outlined), + label: Text(tr.copyAll), onPressed: () async { await copyToClipboard(context, data); if (!context.mounted) { diff --git a/lib/widgets/custom_alert_dialog.dart b/lib/widgets/custom_alert_dialog.dart index 8cfef6440..f1f98273c 100644 --- a/lib/widgets/custom_alert_dialog.dart +++ b/lib/widgets/custom_alert_dialog.dart @@ -100,9 +100,18 @@ class _CustomAlertDialogState extends State> { @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); + // The title is not flexible in an AlertDialog: a long title on a narrow phone with a large text scale pushed the + // content and the actions out of the dialog. Keep it at most a part of the room left above the keyboard and let it + // scroll there, so the content (which scrolls by itself) and the actions always stay on screen. + final titleMaxHeight = math.max((size.height - MediaQuery.viewInsetsOf(context).bottom) * 0.3, 48).toDouble(); return AlertDialog( - title: widget.title, + title: widget.title == null + ? null + : ConstrainedBox( + constraints: BoxConstraints(maxHeight: titleMaxHeight), + child: SingleChildScrollView(child: widget.title), + ), // This value copied from the default value in AlterDialog and removed horizontal padding. contentPadding: const EdgeInsets.only( // left: 24.0, diff --git a/lib/widgets/desktop_back_handler.dart b/lib/widgets/desktop_back_handler.dart new file mode 100644 index 000000000..45c73a83e --- /dev/null +++ b/lib/widgets/desktop_back_handler.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:tsdm_client/utils/platform.dart'; + +/// Lets desktop users go back with the keyboard and the mouse (GitHub #138). +/// +/// On Windows, macOS and Linux, `Esc` and the mouse "back" side button pop the page the focus is in, exactly like +/// the back button in its app bar. Nothing happens when that page is the first of its navigator (the homepage +/// tabs), so the app is never closed by accident. +/// +/// Sitting between the app's shortcuts and the navigator, this widget sees `Esc` before the app does, so it steps +/// aside whenever somebody closer to the focus owns the key: +/// +/// * A dialog, popup menu or modal bottom sheet is not a page route: the key goes on to the route's own dismiss +/// action, which closes it when its barrier is dismissible. +/// * A focused text input keeps the key: the text editing shortcuts of the app stop it, and editors implementing +/// [TextInputClient] (the BBCode editor) are skipped here too, so typing in the search box or the editor never +/// leaves the page. +/// +/// A persistent bottom sheet (the expanded reply editor) is a local history entry of its page, so the pop closes +/// the sheet first and the page only on the next press. Pages refusing to pop (an unsaved draft asking first) keep +/// deciding through their `PopScope`. +/// +/// On other platforms this widget only builds [child]. +class DesktopBackHandler extends StatelessWidget { + /// Constructor. + const DesktopBackHandler({required this.child, super.key}); + + /// The app content, usually the navigator. + final Widget child; + + /// Whether the handler is active, overridable in tests. + @visibleForTesting + static bool? enabledOverride; + + static bool get _enabled => enabledOverride ?? isDesktop; + + /// Pops the page holding the primary focus, when it is a page route that can be popped. + /// + /// Returns whether a pop was attempted. + @visibleForTesting + static bool goBack() { + final focusContext = FocusManager.instance.primaryFocus?.context; + if (focusContext == null || !focusContext.mounted) { + return false; + } + if (_inTextInput(focusContext)) { + return false; + } + final route = ModalRoute.of(focusContext); + if (route is! PageRoute || !route.isCurrent) { + return false; + } + if (route.isFirst && !route.willHandlePopInternally) { + // The root of a navigator: a pop attempt here would trigger the exit-app `PopScope` of the shell pages. + return false; + } + final navigator = route.navigator; + if (navigator == null) { + return false; + } + // `maybePop` respects `PopScope` of the page and pops local history entries (a persistent bottom sheet) first. + unawaited(navigator.maybePop()); + return true; + } + + /// Whether [context] is inside a text input: an [EditableText] (text fields) or any other editor that talks to the + /// platform text input (the BBCode editor). + static bool _inTextInput(BuildContext context) { + var found = false; + context.visitAncestorElements((element) { + if (element is StatefulElement && element.state is TextInputClient) { + found = true; + return false; + } + return true; + }); + return found; + } + + void _onPointerDown(PointerDownEvent event) { + if (event.kind == PointerDeviceKind.mouse && event.buttons & kBackMouseButton != 0) { + goBack(); + } + } + + KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent || event.logicalKey != LogicalKeyboardKey.escape) { + return KeyEventResult.ignored; + } + return goBack() ? KeyEventResult.handled : KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + if (!_enabled) { + return child; + } + return Listener( + onPointerDown: _onPointerDown, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: _onKeyEvent, + child: child, + ), + ); + } +} diff --git a/lib/widgets/list_app_bar/list_app_bar.dart b/lib/widgets/list_app_bar/list_app_bar.dart index 4d65c4d1f..0f9d2fd2a 100644 --- a/lib/widgets/list_app_bar/list_app_bar.dart +++ b/lib/widgets/list_app_bar/list_app_bar.dart @@ -188,143 +188,119 @@ class ListAppBar extends StatelessWidget implements PreferredSizeWidget { // Using three or more actions violates material design spec, but just do it. const NoticeButton(), + // Current page as a tonal pill ("3 / 12"), opening the jump dialog. if (onJumpPage != null) - TextButton( - onPressed: canJumpPage ? () async => _jumpPage(context, currentPage, totalPages) : null, - child: Text('${canJumpPage ? currentPage : "-"}'), - ), - PopupMenuButton( - itemBuilder: (context) => [ - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.refresh), - child: Row( - children: [ - const Icon(Icons.refresh_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.networkList.actionRefresh), - ], - ), - ), - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.copyUrl), - child: Row( - children: [ - const Icon(Icons.copy_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.networkList.actionCopyUrl), - ], - ), - ), - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.openInBrowser), - child: Row( - children: [ - const Icon(Icons.open_in_browser), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.networkList.actionOpenInBrowser), - ], - ), - ), - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.backToTop), - child: Row( - children: [ - const Icon(Icons.vertical_align_top_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.networkList.actionBackToTop), - ], - ), - ), - if (showReverseOrderAction) - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.reverseOrder), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Tooltip( + message: context.t.jumpDialog.title, + child: FilledButton.tonal( + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + ), + onPressed: canJumpPage ? () async => _jumpPage(context, currentPage, totalPages) : null, child: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon(reverseOrder ? Icons.align_vertical_bottom_outlined : Icons.align_vertical_top_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text( - reverseOrder - ? context.t.networkList.actionForwardOrder - : context.t.networkList.actionReverseOrder, - ), + const Icon(Icons.auto_stories_outlined, size: 16), + sizedBoxW4H4, + Text('${canJumpPage ? currentPage : "-"}'), + if (canJumpPage && totalPages > 0) + Text(' / $totalPages', style: Theme.of(context).textTheme.labelSmall), ], ), ), - - // Custom items. - ...customMenuItems.mapIndexed( - (idx, e) => PopupMenuItem( - value: MenuItemId.custom(idx), - child: Row(children: [Icon(e.icon), sizedBoxPopupMenuItemIconSpacing, Text(e.description)]), - ), ), + ), + PopupMenuButton( + // Same items as before, grouped: this page (refresh, back to top, order), the page's own items, its link, + // then the app wide places, and the debug log when debug operations are on. + itemBuilder: (context) { + PopupMenuItem item(MenuItemId value, IconData icon, String text, {bool enabled = true}) => + PopupMenuItem( + enabled: enabled, + value: value, + child: Row( + children: [ + Icon(icon), + sizedBoxPopupMenuItemIconSpacing, + Flexible(child: Text(text)), + ], + ), + ); + const divider = PopupMenuDivider(height: 8); + return >[ + /** + * Actions available in current page. + */ + item(MenuItemId.fixed(MenuActions.refresh), Icons.refresh_outlined, context.t.networkList.actionRefresh), + item( + MenuItemId.fixed(MenuActions.backToTop), + Icons.vertical_align_top_outlined, + context.t.networkList.actionBackToTop, + ), + if (showReverseOrderAction) + item( + MenuItemId.fixed(MenuActions.reverseOrder), + reverseOrder ? Icons.align_vertical_bottom_outlined : Icons.align_vertical_top_outlined, + reverseOrder ? context.t.networkList.actionForwardOrder : context.t.networkList.actionReverseOrder, + ), - const PopupMenuDivider(), + // Custom items. + if (customMenuItems.isNotEmpty) ...[ + divider, + ...customMenuItems.mapIndexed((idx, e) => item(MenuItemId.custom(idx), e.icon, e.description)), + ], - /** - * Global actions - */ - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.openInApp), - child: Row( - children: [ - const Icon(Symbols.open_in_phone), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.openInAppPage.entryTooltip), - ], + // Link of the page. + divider, + item(MenuItemId.fixed(MenuActions.copyUrl), Icons.copy_outlined, context.t.networkList.actionCopyUrl), + item( + MenuItemId.fixed(MenuActions.openInBrowser), + Icons.open_in_browser, + context.t.networkList.actionOpenInBrowser, ), - ), - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.openSearchPage), - child: Row( - children: [ - const Icon(Icons.search_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.searchPage.title), - ], + + /** + * Global actions + */ + divider, + item( + MenuItemId.fixed(MenuActions.openInApp), + Symbols.open_in_phone, + context.t.openInAppPage.entryTooltip, ), - ), - PopupMenuItem( - enabled: isLogin, - value: MenuItemId.fixed(MenuActions.profile), - child: Row( - children: [ - const Icon(Icons.person_outline), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.profilePage.title), - ], + item(MenuItemId.fixed(MenuActions.openSearchPage), Icons.search_outlined, context.t.searchPage.title), + item( + MenuItemId.fixed(MenuActions.profile), + Icons.person_outline, + context.t.profilePage.title, + enabled: isLogin, ), - ), - PopupMenuItem( - enabled: isLogin, - value: MenuItemId.fixed(MenuActions.openNoticePage), - child: Row( - children: [ - const Icon(Icons.notifications_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.noticePage.title), - ], + item( + MenuItemId.fixed(MenuActions.openNoticePage), + Icons.notifications_outlined, + context.t.noticePage.title, + enabled: isLogin, ), - ), - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.openSettingsPage), - child: Row( - children: [ - const Icon(Icons.settings_outlined), - sizedBoxPopupMenuItemIconSpacing, - Text(context.t.general.settings), - ], + item( + MenuItemId.fixed(MenuActions.openSettingsPage), + Icons.settings_outlined, + context.t.general.settings, ), - ), - if (context.read().state.settingsMap.enableDebugOperations) ...>[ - const PopupMenuDivider(), - PopupMenuItem( - value: MenuItemId.fixed(MenuActions.debugViewLog), - child: Text(context.t.settingsPage.debugSection.viewLog.title), - ), - ], - ], + if (context.read().state.settingsMap.enableDebugOperations) ...[ + divider, + item( + MenuItemId.fixed(MenuActions.debugViewLog), + Icons.bug_report_outlined, + context.t.settingsPage.debugSection.viewLog.title, + ), + ], + ]; + }, onSelected: (item) async { switch (item.action) { case MenuActions.refresh: diff --git a/lib/widgets/network_indicator_image.dart b/lib/widgets/network_indicator_image.dart index 6af846c3c..a4083c372 100644 --- a/lib/widgets/network_indicator_image.dart +++ b/lib/widgets/network_indicator_image.dart @@ -12,6 +12,7 @@ class NetworkIndicatorImage extends StatelessWidget { this.maxHeight, this.minWidth, this.minHeight, + this.fit, super.key, }); @@ -36,6 +37,9 @@ class NetworkIndicatorImage extends StatelessWidget { /// Min image height. final double? minHeight; + /// How the image is fitted into its box; null keeps the image's default (scaled down only, never enlarged). + final BoxFit? fit; + @override Widget build(BuildContext context) => CachedImage( src, @@ -45,5 +49,6 @@ class NetworkIndicatorImage extends StatelessWidget { maxHeight: maxHeight, minWidth: minWidth, minHeight: minHeight, + fit: fit, ); } diff --git a/lib/widgets/quoted_text.dart b/lib/widgets/quoted_text.dart index 2b29bef5f..772e97e05 100644 --- a/lib/widgets/quoted_text.dart +++ b/lib/widgets/quoted_text.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// Widget to show some quoted text. class QuotedText extends StatelessWidget { @@ -19,59 +20,44 @@ class QuotedText extends StatelessWidget { @override Widget build(BuildContext context) { - final quotedColor = Theme.of(context).colorScheme.tertiary; - final quotedStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(color: quotedColor); - - final iconHead = Transform.rotate( - angle: 180 * pi / 180, - child: Icon(Icons.format_quote_rounded, size: 28, color: quotedColor), - ); - - final spanHead = TextSpan(children: [WidgetSpan(child: iconHead)]); - - final iconTail = Icon(Icons.format_quote_rounded, size: 28, color: quotedColor); - - final spanTail = TextSpan(children: [WidgetSpan(child: iconTail)]); + final colorScheme = Theme.of(context).colorScheme; + final quotedColor = colorScheme.tertiary; + final quotedStyle = Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant); + final Widget content; if (text != null) { - return Text.rich( - TextSpan( - children: [ - spanHead, - TextSpan(text: ' $text '), - spanTail, - ], - ), - ); + content = Text(text!, style: quotedStyle); + } else if (span != null) { + content = Text.rich(TextSpan(style: quotedStyle, children: [span!])); + } else { + // Impossible. + return const SizedBox.shrink(); } - if (span != null) { - // Because we want to let the start quoted icon lay on the left of - // the content span, but rich text widgets and span widgets do not - // provide such api, we have to wrap the head and the rest of contents - // in two rich text and put in a row. - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text.rich(WidgetSpan(child: Align(child: iconHead))), - sizedBoxW4H4, - Expanded( - child: Text.rich( - TextSpan( - children: [ - // Add a line feed to ensure the child content is lower than - // start quote icon in vertical direction. - TextSpan(style: quotedStyle, children: [span!]), - WidgetSpan(child: iconTail), - ], + // A tinted block with an accent bar on the start side and one opening quote mark: reads as quoted without the + // large quote icons around the text. The rich content (links, images, emojis) keeps its own styles. + return ClipRRect( + borderRadius: BorderRadius.circular(appInnerRadius), + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.35), + border: Border(left: BorderSide(color: quotedColor, width: 3)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Transform.rotate( + angle: pi, + child: Icon(Icons.format_quote_rounded, size: 18, color: quotedColor), ), - ), + sizedBoxW8H8, + Expanded(child: content), + ], ), - ], - ); - } - - // Impossible. - return Container(); + ), + ), + ); } } diff --git a/lib/widgets/reply_bar/reply_bar.dart b/lib/widgets/reply_bar/reply_bar.dart index a094e5033..6d5b565e2 100644 --- a/lib/widgets/reply_bar/reply_bar.dart +++ b/lib/widgets/reply_bar/reply_bar.dart @@ -13,6 +13,7 @@ import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; import 'package:tsdm_client/features/authentication/repository/models/models.dart'; import 'package:tsdm_client/features/chat/models/models.dart'; +import 'package:tsdm_client/features/editor/widgets/editor_frame.dart'; import 'package:tsdm_client/features/editor/widgets/rich_editor.dart'; import 'package:tsdm_client/features/editor/widgets/toolbar.dart'; import 'package:tsdm_client/features/root/models/models.dart'; @@ -26,6 +27,7 @@ import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/bbcode/spoiler_normalizer.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/platform.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; import 'package:tsdm_client/widgets/reply_bar/bloc/reply_bloc.dart'; import 'package:tsdm_client/widgets/reply_bar/models/reply_types.dart'; @@ -109,6 +111,10 @@ class _ReplyBarWrapperState extends State { final c = showBottomSheet( context: context, + // Material 3 limits a bottom sheet to 640dp: on a phone in landscape the sheet was narrower than the page and + // the collapsed bar showed on both sides of it, as a second reply box (feedback 111). The sheet spans the page + // like the bar; its content keeps to the reading column by itself. + constraints: const BoxConstraints(), builder: (_) => _ReplyBar( controller: widget.controller, outerTextController: controller, @@ -208,21 +214,59 @@ class _ReplyBarWrapperState extends State { buildWhen: (prev, curr) => prev.status != curr.status, builder: (context, state) { final loading = state.status == ReplyStatus.loading; - return ColoredBox( - color: Theme.of(context).colorScheme.surfaceContainerLow, + final colorScheme = Theme.of(context).colorScheme; + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(appSurfaceRadius), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ); + // The background reaches the screen edges; the field stays inside the side insets (cutout in landscape) and + // above the gesture bar. Hosts do not wrap the bar in a horizontal SafeArea, which left a blank strip beside + // the bar (feedback 110): with one, the insets are already consumed here and this adds nothing. + final sideInsets = MediaQuery.paddingOf(context); + return DecoratedBox( + key: const ValueKey('reply-bar-background'), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + border: Border(top: BorderSide(color: colorScheme.outlineVariant.withValues(alpha: 0.6))), + ), child: Padding( - padding: edgeInsetsL12T12R12B12.add(context.safePadding()), - child: TextField( - controller: controller, - readOnly: true, - enabled: onTapCallback != null, - // Follow the app theme (outlined) like the expanded editor does, so both states of the reply box - // share the same look. - decoration: InputDecoration( - hintText: context.t.threadPage.sendReplyHint, - suffix: loading ? sizedCircularProgressIndicator : null, + padding: edgeInsetsL12T8R12B8 + .add(EdgeInsets.only(left: sideInsets.left, right: sideInsets.right)) + .add(context.safePadding()), + // Not AppContentWidth: its Align takes all the height it is offered, and a host that gives the bar a + // bounded height (Scaffold.bottomNavigationBar) would get a bar as tall as the page, covering the editor + // sheet above it. heightFactor 1 keeps the bar as tall as its field. + child: Align( + alignment: Alignment.topCenter, + heightFactor: 1, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: appReadingMaxWidth), + // One rounded box: the same field whether it invites a reply, keeps an unsent one, or explains why + // replying is not possible (need login, thread closed). + child: TextField( + key: const ValueKey('reply-bar-field'), + controller: controller, + readOnly: true, + enabled: onTapCallback != null, + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: colorScheme.surface, + hintText: context.t.threadPage.sendReplyHint, + prefixIcon: Icon( + onTapCallback == null ? Icons.lock_outline : Icons.edit_outlined, + size: 20, + color: onTapCallback == null ? colorScheme.outline : colorScheme.primary, + ), + suffix: loading ? sizedCircularProgressIndicator : null, + border: border, + enabledBorder: border, + disabledBorder: border, + focusedBorder: border.copyWith(borderSide: BorderSide(color: colorScheme.primary)), + ), + onTap: onTapCallback, + ), ), - onTap: onTapCallback, ), ), ); @@ -550,9 +594,8 @@ final class _ReplyBarState extends State<_ReplyBar> with LoggerMixin { /// Build an editor with bbcode support. Widget _buildRichEditor(BuildContext context) { - return InputDecorator( - isFocused: focusNode.hasFocus, - decoration: const InputDecoration(), + return EditorFrame( + focusNode: focusNode, child: _wrapWithSendShortcuts( RichEditor( // Initial text is the text passed from outside. @@ -568,25 +611,46 @@ final class _ReplyBarState extends State<_ReplyBar> with LoggerMixin { /// Currently showing the floor user is replying to (if any), and a button to /// set to reply to thread (not any floor). Widget _buildHintTextRow(BuildContext context) { + // No grip above the editor: the collapse button below is the one control that closes the sheet (#139), a second + // one for the same thing only took room. Swiping the sheet down still works without it. + final colorScheme = Theme.of(context).colorScheme; if (_hintText == null || _closed || !_hasLogin) { - // The size here is actually a padding on the top of editor body. - // But it's here. return sizedBoxW12H12; } - final outlineColor = Theme.of(context).colorScheme.outline; - return Padding( - padding: edgeInsetsL12T4R12, - child: Row( - children: [ - Text(_hintText!, style: Theme.of(context).textTheme.labelLarge?.copyWith(color: outlineColor)), - const Spacer(), - IconButton( - icon: Icon(Icons.clear_outlined, color: outlineColor, size: 16), - tooltip: context.t.replyBar.notReplyToFloorTip, - onPressed: _clearTextAndHint, + // The floor this reply goes to, as a pill that can be dropped to reply to the thread instead. + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + sizedBoxW12H12, + Padding( + padding: edgeInsetsL12R12, + child: AppInsetBlock( + color: colorScheme.secondaryContainer, + padding: const EdgeInsets.only(left: 12), + child: Row( + children: [ + Icon(Icons.reply_outlined, size: 18, color: colorScheme.onSecondaryContainer), + sizedBoxW8H8, + Expanded( + child: Text( + _hintText!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge?.copyWith(color: colorScheme.onSecondaryContainer), + ), + ), + IconButton( + icon: Icon(Icons.clear_outlined, color: colorScheme.onSecondaryContainer, size: 16), + tooltip: context.t.replyBar.notReplyToFloorTip, + visualDensity: VisualDensity.compact, + onPressed: _clearTextAndHint, + ), + ], + ), ), - ], - ), + ), + ], ); } @@ -613,10 +677,17 @@ final class _ReplyBarState extends State<_ReplyBar> with LoggerMixin { null => sizedBoxEmpty, _BottomPanelType.none => sizedBoxEmpty, _BottomPanelType.keyboard => sizedBoxEmpty, - _BottomPanelType.toolbar => EditorToolbar( - bbcodeController: _replyRichController, - disabledFeatures: fullScreen ? widget.fullScreenDisabledEditorFeatures : widget.disabledEditorFeatures, - editorFocusNode: focusNode, + // The panel color reaches the screen edges, the toolbar buttons stay out of the side insets. + _BottomPanelType.toolbar => Padding( + padding: EdgeInsets.only( + left: MediaQuery.paddingOf(context).left, + right: MediaQuery.paddingOf(context).right, + ), + child: EditorToolbar( + bbcodeController: _replyRichController, + disabledFeatures: fullScreen ? widget.fullScreenDisabledEditorFeatures : widget.disabledEditorFeatures, + editorFocusNode: focusNode, + ), ), }; }, @@ -659,18 +730,35 @@ final class _ReplyBarState extends State<_ReplyBar> with LoggerMixin { } Widget _buildContent(BuildContext context, ReplyState state) { + // The sheet is not in the safe area of the page: like the bar, its background reaches the screen edges and its + // editor and buttons stay out of the side insets (cutout in landscape, feedback 110). + final insets = MediaQuery.paddingOf(context); + final sides = EdgeInsets.only(left: insets.left, right: insets.right); + return LayoutBuilder( + // The editor, its toolbar and buttons stay in the reading column of the page on wide windows; the sheet itself + // and the mobile panel keep the full width. + builder: (context, constraints) => _buildContentIn( + context, + state, + appCenteredPadding(constraints.maxWidth - sides.horizontal, maxWidth: appReadingMaxWidth, minPadding: 0) + + sides, + ), + ); + } + + Widget _buildContentIn(BuildContext context, ReplyState state, EdgeInsets horizontal) { final tr = context.t.replyBar; final content = Column( mainAxisSize: MainAxisSize.min, children: [ // Reply hint row. - _buildHintTextRow(context), + Padding(padding: horizontal, child: _buildHintTextRow(context)), // Editor. Flexible( child: Padding( - padding: edgeInsetsL12T4R12B4, + padding: horizontal.add(const EdgeInsets.fromLTRB(12, 8, 12, 4)), child: Listener( onPointerUp: // Only collapse editor toolbar on mobile platforms. @@ -686,9 +774,9 @@ final class _ReplyBarState extends State<_ReplyBar> with LoggerMixin { ), ), - _buildDesktopToolbar(context, state), + Padding(padding: horizontal, child: _buildDesktopToolbar(context, state)), Padding( - padding: edgeInsetsL12R12B12, + padding: horizontal.add(edgeInsetsL12R12B12), child: Row( children: [ // Only control expand or collapse on mobile platforms. @@ -729,7 +817,14 @@ final class _ReplyBarState extends State<_ReplyBar> with LoggerMixin { }, ), const Spacer(), - FilledButton.tonal(onPressed: widget.controller.closeEditor, child: const Icon(Icons.unfold_less)), + Tooltip( + // "Collapse": the draft is kept in the reply box below. + message: MaterialLocalizations.of(context).expandedIconTapHint, + child: FilledButton.tonal( + onPressed: widget.controller.closeEditor, + child: const Icon(Icons.unfold_less), + ), + ), sizedBoxW8H8, // Send Button FilledButton( diff --git a/lib/widgets/selectable_list_tile.dart b/lib/widgets/selectable_list_tile.dart index 2c934bec5..e70be2407 100644 --- a/lib/widgets/selectable_list_tile.dart +++ b/lib/widgets/selectable_list_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; /// List tile with selectable state, closer to the Android native component style. class SelectableListTile extends ListTile { @@ -53,8 +54,9 @@ class SelectableListTile extends ListTile { child: Padding( padding: edgeInsetsL8R8, child: ClipRRect( - // The height of ListTile is default to 48, to get a smooth radius border, the height is 24. - borderRadius: const BorderRadius.all(Radius.circular(24)), + // Same inner radius as the other rounded blocks: a 24 radius only suits one line rows, the options of + // the pickers wrap to two or three lines at large text scales. + borderRadius: const BorderRadius.all(Radius.circular(appInnerRadius)), child: ColoredBox(color: selectedTileColor ?? Theme.of(context).colorScheme.secondaryContainer), ), ), diff --git a/test/regression/test_064_guide_index_x5_test.dart b/test/regression/test_064_guide_index_x5_test.dart index 70ba6643c..0e0ce2e71 100644 --- a/test/regression/test_064_guide_index_x5_test.dart +++ b/test/regression/test_064_guide_index_x5_test.dart @@ -239,23 +239,26 @@ void main() { return router; } - testWidgets('shows every module with its title, 更多 button, first 10 items and the 抢沙发 chip', (tester) async { + testWidgets('one card: a tab per module, the first 10 items of the selected one, 更多 and the 抢沙发 chip', ( + tester, + ) async { tester.view.physicalSize = const Size(1080, 6000); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final modules = parseGuideIndex(_index()); await pump(tester, _FixtureRepository(modules)); - expect(find.byType(GuideModuleCard), findsNWidgets(4)); + expect(find.text('Forum guide'), findsOneWidget); for (final module in modules) { - expect(find.widgetWithText(GuideModuleCard, module.title), findsOneWidget); - expect(find.widgetWithText(ActionChip, module.title), findsOneWidget, reason: 'nav chip'); + expect(find.text(module.title), findsOneWidget, reason: 'tab'); } - expect(find.widgetWithText(TextButton, 'More'), findsNWidgets(4)); + expect(find.byType(GuideModuleList), findsOneWidget, reason: 'only the selected module is listed'); + expect(find.widgetWithText(TextButton, 'More'), findsOneWidget); expect(find.widgetWithText(ActionChip, 'Sofa'), findsOneWidget); - // 最新热门: 10 of the 30 items, the first one highlighted in the error color with its forum and participants. - final hot = find.byType(GuideModuleCard).first; + // 最新热门 is selected first: 10 of the 30 items, the first one highlighted in the error color with its forum and + // participants. + final hot = find.byType(GuideModuleList); for (final item in modules[0].items.take(10)) { expect( find.descendant(of: hot, matching: find.text(item.title)), @@ -270,8 +273,13 @@ void main() { final title = tester.widget(find.descendant(of: hot, matching: find.text('部分用户无法登录的问题我说几句'))); expect(title.style?.color, Theme.of(context).colorScheme.error); expect(find.descendant(of: hot, matching: find.text(modules[0].items[10].title)), findsNothing); + expect(find.text('暂时还没有帖子'), findsNothing); + // 最新精华 is empty: the page's own message. + await tester.tap(find.text('最新精华')); + await tester.pumpAndSettle(); expect(find.text('暂时还没有帖子'), findsOneWidget); + expect(find.text('部分用户无法登录的问题我说几句'), findsNothing); expect(tester.takeException(), isNull); }); @@ -306,11 +314,18 @@ void main() { router.pop(); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ActionChip, '最新发表')); + // Another tab: its threads, and 更多 follows the selection. + await tester.tap(find.text('最新发表')); + await tester.pumpAndSettle(); + expect(find.text(parseGuideIndex(_index())[3].items.first.title), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, 'More')); await tester.pumpAndSettle(); expect(router.state.uri.queryParameters['url'], guideUrl('newthread')); + expect(router.state.uri.queryParameters['title'], '最新发表'); router.pop(); await tester.pumpAndSettle(); + await tester.tap(find.text('最新热门')); + await tester.pumpAndSettle(); await tester.tap(find.text('若闲小阁').first); await tester.pumpAndSettle(); @@ -323,7 +338,7 @@ void main() { await pump(tester, const _FixtureRepository([], fail: true)); expect(find.text('Failed to load, tap to retry'), findsOneWidget); expect(find.widgetWithText(ActionChip, 'Sofa'), findsOneWidget); - expect(find.byType(GuideModuleCard), findsNothing); + expect(find.byType(GuideModuleList), findsNothing); // Retry keeps failing here, the row must survive it. await tester.tap(find.text('Failed to load, tap to retry')); await tester.pumpAndSettle(); diff --git a/test/regression/test_106_tablet_navigation_test.dart b/test/regression/test_106_tablet_navigation_test.dart index f18314c56..38b64da70 100644 --- a/test/regression/test_106_tablet_navigation_test.dart +++ b/test/regression/test_106_tablet_navigation_test.dart @@ -145,8 +145,14 @@ void main() { } /// Tap the destination of [tab] like a user, on its icon or (bar and drawer: the rail hides its labels) its label. + /// + /// The side navigation has nine entries since 2026-09-27 and the rail scrolls in short windows: like a user, scroll + /// the destination into view first (settings is the last entry). Future tap(WidgetTester tester, HomeTab tab, {bool onLabel = false}) async { - await tester.tap(onLabel ? find.text(label(tab)) : icon(tab)); + final target = onLabel ? find.text(label(tab)) : icon(tab); + await tester.ensureVisible(target); + await tester.pump(); + await tester.tap(target); await tester.pump(); } diff --git a/test/regression/test_160_current_title_account_test.dart b/test/regression/test_160_current_title_account_test.dart new file mode 100644 index 000000000..788776f76 --- /dev/null +++ b/test/regression/test_160_current_title_account_test.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; +import 'package:tsdm_client/features/profile/models/secondary_title.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; + +/// The secondary title badge of the current account (homepage, own profile, medal & title hub) comes from the titles +/// page of that account. It must never show for another account: not after a switch, not after a logout, and not when +/// the answer of a request started for the previous account arrives late. +/// +/// Synthetic accounts and titles, no network. +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); +const _bob = UserLoginInfo(username: 'Bob', uid: 1001); + +SecondaryTitle _title(int id, {bool activated = false}) => + SecondaryTitle(id: id, name: 'Title $id', imageUrl: 'https://example.invalid/title/$id.gif', activated: activated); + +final class _Harness { + _Harness() { + cubit = CurrentTitleCubit( + currentUid: () => currentUid, + authStatus: auth.stream, + fetchTitles: () => TaskEither(() { + requests++; + final completer = Completer>>(); + pending.add(completer); + return completer.future; + }), + ); + } + + final auth = StreamController.broadcast(sync: true); + late final CurrentTitleCubit cubit; + int? currentUid; + int requests = 0; + final pending = >>>[]; + + void login(UserLoginInfo user) { + currentUid = user.uid; + auth.add(AuthStatusAuthed(user)); + } + + Future answer(List titles, {int index = 0}) async { + pending[index].complete(Right(titles)); + await pumpEventQueue(); + } + + Future dispose() async { + await cubit.close(); + await auth.close(); + } +} + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + late _Harness h; + setUp(() => h = _Harness()); + tearDown(() => h.dispose()); + + test('the title in use is read once for the account and only matches that account', () async { + h.login(_alice); + final loading = h.cubit.ensureLoaded(); + expect(h.cubit.state.status, CurrentTitleStatus.loading); + // A second badge on screen does not read the page again. + unawaited(h.cubit.ensureLoaded()); + await h.answer([_title(1), _title(2, activated: true)]); + await loading; + + expect(h.requests, 1); + expect(h.cubit.state.status, CurrentTitleStatus.success); + expect(h.cubit.state.imageUrlFor(_alice.uid), 'https://example.invalid/title/2.gif'); + expect(h.cubit.state.imageUrlFor(_bob.uid), isNull, reason: 'never shown for another user'); + expect(h.cubit.state.imageUrlFor(null), isNull); + + await h.cubit.ensureLoaded(); + expect(h.requests, 1, reason: 'known already'); + final forced = h.cubit.ensureLoaded(force: true); + await h.answer([_title(1)], index: 1); + await forced; + expect(h.requests, 2); + expect(h.cubit.state.imageUrlFor(_alice.uid), isNull, reason: 'the title was unset meanwhile'); + }); + + test('switching accounts drops the title at once and discards the late answer for the previous account', () async { + h.login(_alice); + final aliceLoad = h.cubit.ensureLoaded(); + + // Switch while Alice's page is still loading. + h.auth.add(const AuthStatusLoading()); + expect(h.cubit.state.uid, isNull); + await h.cubit.ensureLoaded(); + expect(h.requests, 1, reason: 'nothing is read while the account changes'); + h.login(_bob); + expect(h.cubit.state.uid, _bob.uid); + + await h.answer([_title(7, activated: true)]); + await aliceLoad; + expect(h.cubit.state.uid, _bob.uid); + expect(h.cubit.state.title, isNull, reason: "Alice's title must not become Bob's"); + expect(h.cubit.state.imageUrlFor(_bob.uid), isNull); + expect(h.cubit.state.imageUrlFor(_alice.uid), isNull); + + final bobLoad = h.cubit.ensureLoaded(); + await h.answer([_title(9, activated: true)], index: 1); + await bobLoad; + expect(h.cubit.state.imageUrlFor(_bob.uid), 'https://example.invalid/title/9.gif'); + }); + + test('logging out forgets the title', () async { + h.login(_alice); + final load = h.cubit.ensureLoaded(); + await h.answer([_title(2, activated: true)]); + await load; + expect(h.cubit.state.imageUrlFor(_alice.uid), isNotNull); + + h + ..currentUid = null + ..auth.add(const AuthStatusNotAuthed()); + expect(h.cubit.state.uid, isNull); + expect(h.cubit.state.title, isNull); + expect(h.cubit.state.imageUrlFor(_alice.uid), isNull); + }); + + test('the my titles page hands its titles over only for the account it was opened with', () async { + h.login(_alice); + h.cubit.record(uid: _bob.uid, titles: [_title(3, activated: true)]); + expect(h.cubit.state.title, isNull, reason: 'not the current account'); + + h.cubit.record(uid: _alice.uid, titles: [_title(3, activated: true), _title(4)]); + expect(h.cubit.state.status, CurrentTitleStatus.success); + expect(h.cubit.state.imageUrlFor(_alice.uid), 'https://example.invalid/title/3.gif'); + + // Unset on the page. + h.cubit.record(uid: _alice.uid, titles: [_title(3), _title(4)]); + expect(h.cubit.state.status, CurrentTitleStatus.success); + expect(h.cubit.state.imageUrlFor(_alice.uid), isNull); + await h.cubit.ensureLoaded(); + expect(h.requests, 0, reason: 'what the page read is enough'); + }); + + test('a record supersedes a read still in flight', () async { + h.login(_alice); + final load = h.cubit.ensureLoaded(); + h.cubit.record(uid: _alice.uid, titles: [_title(5, activated: true)]); + await h.answer([_title(6, activated: true)]); + await load; + expect(h.cubit.state.imageUrlFor(_alice.uid), 'https://example.invalid/title/5.gif'); + }); + + test('a failed read is reported and retried on the next request', () async { + h.login(_alice); + final load = h.cubit.ensureLoaded(); + h.pending.single.complete(Left(HttpRequestFailedException(500))); + await pumpEventQueue(); + await load; + expect(h.cubit.state.status, CurrentTitleStatus.failure); + expect(h.cubit.state.imageUrlFor(_alice.uid), isNull); + + final retry = h.cubit.ensureLoaded(); + expect(h.requests, 2); + await h.answer([_title(8, activated: true)], index: 1); + await retry; + expect(h.cubit.state.imageUrlFor(_alice.uid), 'https://example.invalid/title/8.gif'); + }); +} diff --git a/test/regression/test_161_secondary_title_sources_test.dart b/test/regression/test_161_secondary_title_sources_test.dart new file mode 100644 index 000000000..826b81eb0 --- /dev/null +++ b/test/regression/test_161_secondary_title_sources_test.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/profile/utils/parse_profile.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:universal_html/html.dart' as uh; +import 'package:universal_html/parsing.dart'; + +/// Where the secondary title images shown in the app come from. +/// +/// * Thread floors: the author column of the floor itself (`div.tsdmtitle-badges`), public X5 sample of test #011. +/// * Profile pages: the same block inside the profile content only; the X5 profile sample has none, the markup on a +/// profile page is not established by any sample yet. +String _data(String name) => File('test/data/$name').readAsStringSync(); + +const _threadTitle = 'https://img.tsdm39.com/img01/title/无职转生.gif'; + +uh.Element _floor(uh.Document doc) => doc.querySelector('div#post_77983102')!; + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + group('thread floor', () { + test('the secondary title comes from the author column of the floor', () { + final doc = parseHtmlDocument(_data('locked_purchase_x5.html')); + final post = Post.fromPostNode(_floor(doc), 1); + expect(post, isNotNull); + expect(post!.secondBadge, _threadTitle); + }); + + test('a floor whose author uses no title has none, whatever the other floors show', () { + final doc = parseHtmlDocument(_data('locked_purchase_x5.html')); + final withTitle = _floor(doc); + final withoutTitle = withTitle.clone(true) as uh.Element; + withoutTitle.querySelectorAll('div.tsdmtitle-badges').forEach((e) => e.remove()); + withTitle.parent!.append(withoutTitle); + + expect(Post.fromPostNode(withTitle, 1)?.secondBadge, _threadTitle); + expect(Post.fromPostNode(withoutTitle, 1)?.secondBadge, isNull); + }); + }); + + group('profile page', () { + test('the X5 profile sample renders no title block: nothing is shown', () { + expect(parseProfileSecondaryTitleUrl(parseHtmlDocument(_data('profile_self_x5.html'))), isNull); + }); + + test('the title block inside the profile content belongs to the owner', () { + final doc = parseHtmlDocument(''' + + +
+

Bob(UID: 1001)

+
Owner title
+
+'''); + expect(parseProfileSecondaryTitleUrl(doc), 'https://www.tsdm39.com/data/title/owner.gif'); + }); + + test('a title block in the page header (the viewing account) is not the owner', () { + final doc = parseHtmlDocument(''' + +
+

Bob

+'''); + expect(parseProfileSecondaryTitleUrl(doc), isNull); + }); + }); + + test('the badge box keeps the natural 184x100 ratio', () { + expect(SecondaryTitleBadge.heightFor(184), 100); + expect(SecondaryTitleBadge.heightFor(92), 50); + }); +} diff --git a/test/regression/test_162_medal_title_hub_test.dart b/test/regression/test_162_medal_title_hub_test.dart new file mode 100644 index 000000000..04f0a43ab --- /dev/null +++ b/test/regression/test_162_medal_title_hub_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/medal_center/view/medal_title_hub_page.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; + +/// Testers found the title shop only behind avatar > profile > menu > my titles > shop. The medal & title hub links +/// the medal centre, the own titles and the title shop through their existing routes; nothing is bought or switched on +/// the hub itself. +void main() { + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.en); + }); + + Future pump(WidgetTester tester) async { + GoRoute stub(String path) => GoRoute( + path: path, + name: path, + builder: (_, _) => Scaffold(body: Text('page $path')), + ); + final router = GoRouter( + routes: [ + GoRoute(path: '/', builder: (_, _) => const MedalTitleHubPage()), + stub(ScreenPaths.medalCenter), + stub(ScreenPaths.switchTitle), + stub(ScreenPaths.titleShop), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(TranslationProvider(child: MaterialApp.router(routerConfig: router))); + await tester.pumpAndSettle(); + return router; + } + + testWidgets('the hub links the medal centre, my titles and the title shop', (tester) async { + final router = await pump(tester); + expect(find.text(tr.medalTitleHub.title), findsOneWidget); + expect(find.text(tr.medalTitleHub.currentTitle), findsOneWidget); + + for (final (label, path) in [ + (tr.medalCenter.title, ScreenPaths.medalCenter), + (tr.myTitlesPage.title, ScreenPaths.switchTitle), + (tr.titleShop.title, ScreenPaths.titleShop), + ]) { + await tester.tap(find.widgetWithText(ListTile, label)); + await tester.pumpAndSettle(); + expect(router.state.uri.path, path); + expect(find.text('page $path'), findsOneWidget); + router.pop(); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }); + + testWidgets('narrow and large text: every entry stays reachable without overflow', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 560); + addTearDown(tester.view.reset); + tester.platformDispatcher.textScaleFactorTestValue = 1.6; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await pump(tester); + await tester.scrollUntilVisible(find.widgetWithText(ListTile, tr.titleShop.title), 100); + expect(find.widgetWithText(ListTile, tr.titleShop.title), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} + +Translations get tr => LocaleSettings.instance.currentTranslations; diff --git a/test/regression/test_163_full_ui_phase1_test.dart b/test/regression/test_163_full_ui_phase1_test.dart new file mode 100644 index 000000000..434b4abce --- /dev/null +++ b/test/regression/test_163_full_ui_phase1_test.dart @@ -0,0 +1,199 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/homepage/widgets/home_dashboard.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; +import 'package:tsdm_client/features/profile/models/secondary_title.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/card/post_card/post_card.dart'; +import 'package:tsdm_client/widgets/card/post_card/show_user_brief_profile_dialog.dart'; + +/// Full UI redesign, phase 1: the secondary title (184x100 image) is shown complete and large enough on the homepage +/// greeting, floors and the author dialog; a failed read of the current account's title offers a retry instead of +/// looking like "no title"; the shared layout helpers behave on phones and wide windows. +/// +/// Synthetic account and titles, no network. +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); + +void main() { + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.en); + }); + + group('secondary title sizes', () { + // Target approved on 2026-09-27: 240px on wide layouts and 160 to 184px on phones (replaces 184 / 120 to 138). + test('homepage greeting: 240px on wide layouts, past the natural 184px of the image', () { + expect(homeGreetingBadgeWidth(528, compact: false), homeGreetingBadgeWideWidth); + expect(homeGreetingBadgeWidth(1200, compact: false), 240); + expect(homeGreetingBadgeBeside(528, 240), isTrue); + }); + + test('homepage greeting: 160 to 184px on phones, beside the greeting only when it keeps its room', () { + // 360, 390, 412 and 480 wide phones: page padding 12 and card padding 16 on each side. + for (final window in [360.0, 390.0, 412.0, 480.0]) { + final available = window - 56; + final width = homeGreetingBadgeWidth(available, compact: true); + expect(width, inInclusiveRange(160, 184), reason: '$window'); + } + // 412 wide: beside the greeting, which keeps at least 168px. + expect(homeGreetingBadgeBeside(356, homeGreetingBadgeWidth(356, compact: true)), isTrue); + expect(homeGreetingBadgeLayout(356, compact: true), ( + width: homeGreetingBadgeWidth(356, compact: true), + beside: true, + )); + // 384 wide (feedback 110): beside at 160 instead of below with the right half of the card empty. + expect(homeGreetingBadgeLayout(328, compact: true), (width: 160.0, beside: true)); + // 360 wide: beside with the room left, not below 144. + expect(homeGreetingBadgeLayout(304, compact: true), (width: 144.0, beside: true)); + // The same 360 wide phone with 1.3x text: the greeting keeps its room, the badge goes below at 160. + expect(homeGreetingBadgeLayout(304, compact: true, textScale: 1.3), (width: 160.0, beside: false)); + expect(homeGreetingBadgeWidth(600, compact: true), 184); + // Wide layouts keep 240 beside the greeting. + expect(homeGreetingBadgeLayout(528, compact: false), (width: 240.0, beside: true)); + }); + + test('homepage greeting: the badge wraps below the greeting on very narrow windows', () { + final width = homeGreetingBadgeWidth(264, compact: true); + expect(width, 160, reason: 'not shrunk below 160 to squeeze beside the text'); + expect(homeGreetingBadgeBeside(264, width), isFalse); + expect(homeGreetingBadgeLayout(264, compact: true), (width: 160.0, beside: false)); + // Never wider than the card. + expect(homeGreetingBadgeWidth(150, compact: true), 150); + expect(homeGreetingBadgeWidth(200, compact: false), 200); + }); + + test('never wider than the room nor than the image', () { + expect(SecondaryTitleBadge.fitWidth(100), 100); + expect(SecondaryTitleBadge.fitWidth(1000), 184); + expect(SecondaryTitleBadge.fitWidth(1000, preferred: 138), 138); + expect(SecondaryTitleBadge.heightFor(120), closeTo(65.2, 0.1)); + }); + + test('floor author row and author dialog', () { + expect(SecondaryTitleBadge.heightFor(postAuthorSecondBadgeWidth(360)), closeTo(32, 0.001)); + expect(postAuthorSecondBadgeWidth(1280), postAuthorSecondBadgeWidth(360)); + expect(postAuthorSecondBadgeWidth(40), 40); + for (final windowWidth in [320.0, 360.0, 1280.0]) { + final width = SecondaryTitleBadge.fitWidth( + briefProfileDialogContentWidth(windowWidth), + preferred: SecondaryTitleBadge.widthFor(profileBadgeHeight), + ); + expect(SecondaryTitleBadge.heightFor(width), closeTo(64, 0.001)); + } + }); + }); + + group('layout helpers', () { + test('one column on phones, two on wide windows', () { + expect(appColumnsFor(390), 1); + expect(appColumnsFor(1024), 2); + expect(appRowCount(5, 2), 3); + expect(appRowCount(4, 1), 4); + }); + + test('lists are centered at their maximum width', () { + expect(appCenteredPadding(390).horizontal, 24); + expect(appCenteredPadding(1360).left, 200); + }); + + testWidgets('a state view with a long message fits a narrow window', (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 480); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: AppStateView(message: 'A very long message ' * 30, action: const Text('action')), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('action'), findsOneWidget); + }); + }); + + group('current account title badge', () { + late StreamController auth; + late CurrentTitleCubit cubit; + late List>>> pending; + + setUp(() { + auth = StreamController.broadcast(sync: true); + pending = []; + cubit = CurrentTitleCubit( + currentUid: () => _alice.uid, + authStatus: auth.stream, + fetchTitles: () => TaskEither(() { + final completer = Completer>>(); + pending.add(completer); + return completer.future; + }), + ); + auth.add(const AuthStatusAuthed(_alice)); + }); + + tearDown(() async { + await cubit.close(); + await auth.close(); + }); + + Future pumpBadge(WidgetTester tester, {int? uid = 1000}) => tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: Center(child: CurrentAccountTitleBadge(uid: uid, width: 120)), + ), + ), + ), + ), + ); + + testWidgets('a failed read offers a retry, never "no title"', (tester) async { + await pumpBadge(tester); + expect(pending, hasLength(1)); + // The badge room is kept while the title is read. + expect(tester.getSize(find.byType(SecondaryTitlePlaceholder)), Size(120, SecondaryTitleBadge.heightFor(120))); + + pending.single.complete(Left(HttpRequestFailedException(500))); + await tester.pump(); + await tester.pump(); + expect(find.byType(SecondaryTitleRetry), findsOneWidget); + expect(find.byType(SecondaryTitlePlaceholder), findsNothing); + + await tester.tap(find.byType(SecondaryTitleRetry)); + await tester.pump(); + expect(pending, hasLength(2), reason: 'the retry reads the title again'); + expect(find.byType(SecondaryTitlePlaceholder), findsOneWidget); + + // Titles owned, none in use: nothing is shown, and nothing made up. + pending[1].complete(Right([const SecondaryTitle(id: 3, name: 'T', imageUrl: 'x', activated: false)])); + await tester.pump(); + await tester.pump(); + expect(find.byType(SecondaryTitleRetry), findsNothing); + expect(find.byType(SecondaryTitlePlaceholder), findsNothing); + expect(find.byType(SecondaryTitleBadge), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + + testWidgets('another user never gets the loading or retry room of the current account', (tester) async { + await pumpBadge(tester, uid: 2000); + expect(find.byType(SecondaryTitlePlaceholder), findsNothing); + expect(find.byType(SecondaryTitleRetry), findsNothing); + await tester.pumpWidget(const SizedBox.shrink()); + }); + }); +} diff --git a/test/regression/test_164_full_ui_phase2_test.dart b/test/regression/test_164_full_ui_phase2_test.dart new file mode 100644 index 000000000..387f65e3e --- /dev/null +++ b/test/regression/test_164_full_ui_phase2_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/features/chat/models/models.dart'; +import 'package:tsdm_client/features/chat/widgets/chat_frame.dart'; +import 'package:tsdm_client/features/chat/widgets/chat_message_card.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; + +/// Full UI redesign, phase 2 (messages, chat, notifications, search, filters, navigation): which chat bubbles belong +/// to the current account, and the new shared pieces (page switcher, info pills, empty state inside a refreshable +/// scroll view, chat header) fit a narrow phone with a large font. +/// +/// Synthetic data, no network. +ChatMessage _message({String? author, String? uid}) => + ChatMessage(author: author, authorUid: uid, authorAvatarUrl: null, message: 'hi', dateTime: null); + +/// A 320 wide phone with the text scaled to [scale]. +Future _pumpNarrow(WidgetTester tester, Widget child, {double scale = 2}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 560); + addTearDown(tester.view.reset); + await tester.pumpWidget( + MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: Scaffold(body: child), + ), + ), + ); +} + +void main() { + group('own chat messages', () { + test('chat history: the uid decides, a same name with another uid is not mine', () { + expect( + isOwnChatMessage( + _message(author: 'Alice', uid: '1000'), + currentUid: 1000, + currentUsername: 'Alice', + ), + isTrue, + ); + expect( + isOwnChatMessage( + _message(author: 'Alice', uid: '2000'), + currentUid: 1000, + currentUsername: 'Alice', + ), + isFalse, + ); + expect( + isOwnChatMessage( + _message(author: 'Bob', uid: '2000'), + currentUid: 1000, + currentUsername: 'Alice', + ), + isFalse, + ); + }); + + test('chat dialog: without uid the name decides', () { + expect(isOwnChatMessage(_message(author: 'Alice'), currentUid: 1000, currentUsername: 'Alice'), isTrue); + expect(isOwnChatMessage(_message(author: 'Bob'), currentUid: 1000, currentUsername: 'Alice'), isFalse); + }); + + test('unknown account or author: never attributed to the current account', () { + expect( + isOwnChatMessage(_message(author: 'Alice', uid: '1000'), currentUid: null, currentUsername: null), + isFalse, + ); + expect(isOwnChatMessage(_message(), currentUid: 1000, currentUsername: 'Alice'), isFalse); + }); + }); + + group('shared pieces on a narrow phone with a large font', () { + testWidgets('page switcher: fits, shows current / total, disabled without callback', (tester) async { + var next = 0; + await _pumpNarrow( + tester, + Center(child: AppPager(current: 3, total: 12, onNext: () => next++)), + ); + expect(tester.takeException(), isNull); + expect(find.text('3 / 12'), findsOneWidget); + await tester.tap(find.byIcon(Icons.chevron_right)); + expect(next, 1); + final previous = tester.widget( + find.ancestor(of: find.byIcon(Icons.chevron_left), matching: find.byType(IconButton)), + ); + expect(previous.onPressed, isNull); + }); + + testWidgets('page switcher: unknown pages show dashes', (tester) async { + await _pumpNarrow(tester, const Center(child: AppPager(current: null, total: null)), scale: 1); + expect(find.text('- / -'), findsOneWidget); + }); + + testWidgets('info pills wrap and ellipsize long values', (tester) async { + await _pumpNarrow( + tester, + Padding( + padding: const EdgeInsets.all(12), + child: Wrap( + spacing: 8, + runSpacing: 6, + children: [ + AppInfoPill(icon: Icons.person_outline, label: 'a very long user name ' * 6), + AppInfoPill(icon: Icons.forum_outlined, label: 'a very long forum name ' * 6), + const AppInfoPill(icon: Icons.access_time_outlined, label: '2026-09-27 12:00:00'), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(AppInfoPill), findsNWidgets(3)); + }); + + testWidgets('empty state inside a refreshable list keeps a scroll view and does not overflow', (tester) async { + await _pumpNarrow( + tester, + AppScrollableStateView( + physics: const AlwaysScrollableScrollPhysics(), + child: AppStateView(message: 'nothing here ' * 30, scrollable: false), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(SingleChildScrollView), findsOneWidget); + await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -200)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + testWidgets('chat header: long name and status pill fit one line', (tester) async { + await _pumpNarrow( + tester, + ChatPeerHeader(text: 'Chat with ${'someone with a long name ' * 4}', status: 'Online', online: true), + ); + expect(tester.takeException(), isNull); + expect(find.text('Online'), findsOneWidget); + }); + }); +} diff --git a/test/regression/test_165_full_ui_phase3_test.dart b/test/regression/test_165_full_ui_phase3_test.dart new file mode 100644 index 000000000..256376703 --- /dev/null +++ b/test/regression/test_165_full_ui_phase3_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/features/blocking/widgets/block_aware_post.dart'; +import 'package:tsdm_client/features/editor/widgets/editor_frame.dart'; +import 'package:tsdm_client/features/rate/widgets/fast_rate_template_card.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/card/bounty_card.dart'; +import 'package:tsdm_client/widgets/card/code_card.dart'; +import 'package:tsdm_client/widgets/quoted_text.dart'; + +/// Full UI redesign, phase 3 (editors, reply bar, drafts, polls, rates, templates, report, reader controls, floor +/// cards): the new shared pieces fit a narrow phone with a large font, a pinned submit bar stays reachable above the +/// keyboard, and the editor frame follows the focus. +/// +/// Synthetic data, no network. + +/// A 320 wide phone with the text scaled to [scale], optionally with the keyboard open ([keyboard] logical pixels). +Future _pumpNarrow(WidgetTester tester, Widget body, {double scale = 2, double keyboard = 0}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 560); + tester.view.viewInsets = FakeViewPadding(bottom: keyboard); + addTearDown(tester.view.reset); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: MediaQuery.withClampedTextScaling( + minScaleFactor: scale, + maxScaleFactor: scale, + child: Scaffold(body: body), + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + setUpAll(() async => LocaleSettings.setLocale(AppLocale.en)); + + group('shared pieces on a narrow phone with a large font', () { + testWidgets('notice banner wraps a long message with actions', (tester) async { + await _pumpNarrow( + tester, + SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: AppNoticeBanner( + tone: AppNoticeTone.error, + title: 'Refused', + message: 'a long forum message that has to wrap on several lines ' * 4, + actions: [TextButton(onPressed: () {}, child: const Text('Retry'))], + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('Refused'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + }); + + testWidgets('embedded card keeps its header and trailing action, the title wraps', (tester) async { + var tapped = 0; + await _pumpNarrow( + tester, + SingleChildScrollView( + padding: const EdgeInsets.all(6), + child: AppEmbedCard( + icon: Icons.poll_outlined, + title: 'An embedded block with a long title that wraps', + subtitle: 'ends 2026-10-01', + trailing: IconButton(onPressed: () => tapped++, icon: const Icon(Icons.bar_chart_outlined)), + child: const Text('body'), + ), + ), + ); + expect(tester.takeException(), isNull); + await tester.tap(find.byIcon(Icons.bar_chart_outlined)); + expect(tapped, 1); + expect(find.text('body'), findsOneWidget); + }); + + testWidgets('form section and pinned submit bar stay usable with the keyboard open', (tester) async { + var submitted = 0; + await _pumpNarrow( + tester, + Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(12), + child: AppFormSection( + title: 'Scores', + icon: Icons.star_rate_outlined, + children: [ + for (var i = 0; i < 4; i++) TextField(decoration: InputDecoration(labelText: 'Field $i')), + ], + ), + ), + ), + AppBottomActionBar( + child: FilledButton(onPressed: () => submitted++, child: const Text('Submit')), + ), + ], + ), + keyboard: 260, + ); + expect(tester.takeException(), isNull); + // The bar sits above the keyboard: the button is on screen and receives the tap. + final button = tester.getRect(find.text('Submit')); + expect(button.bottom, lessThanOrEqualTo(560 - 260)); + await tester.tap(find.text('Submit')); + expect(submitted, 1); + }); + + testWidgets('editor control bar keeps the trailing send button with many tools', (tester) async { + var sent = 0; + await _pumpNarrow( + tester, + Align( + alignment: Alignment.bottomCenter, + child: EditorControlBar( + leading: [ + for (var i = 0; i < 12; i++) IconButton(onPressed: () {}, icon: const Icon(Icons.format_bold)), + ], + trailing: [FilledButton(onPressed: () => sent++, child: const Icon(Icons.send))], + ), + ), + ); + expect(tester.takeException(), isNull); + await tester.tap(find.byIcon(Icons.send)); + expect(sent, 1); + }); + + testWidgets('editor frame border follows the focus of the editor', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await _pumpNarrow( + tester, + Padding( + padding: const EdgeInsets.all(12), + child: EditorFrame( + focusNode: focusNode, + child: TextField(focusNode: focusNode), + ), + ), + scale: 1, + ); + Color border() { + final box = tester.widget( + find.descendant(of: find.byType(EditorFrame), matching: find.byType(DecoratedBox)).first, + ); + return ((box.decoration as BoxDecoration).border! as Border).top.color; + } + + final primary = Theme.of(tester.element(find.byType(EditorFrame))).colorScheme.primary; + final outlineVariant = Theme.of(tester.element(find.byType(EditorFrame))).colorScheme.outlineVariant; + expect(border(), outlineVariant); + + // requestFocus applies the change in a microtask and schedules no frame by itself: the first pump only flushes + // that microtask (the node notifies, the frame is marked dirty), the second pump builds the frame. In the app a + // tap or the editor opening always leads to that next frame. + focusNode.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasFocus, isTrue); + expect(border(), primary); + + focusNode.unfocus(); + await tester.pump(); + await tester.pump(); + expect(focusNode.hasFocus, isFalse); + expect(border(), outlineVariant); + }); + }); + + group('floor cards and quotes', () { + testWidgets('code card, bounty card and a quote fit a narrow phone with a large font', (tester) async { + await _pumpNarrow( + tester, + SingleChildScrollView( + padding: const EdgeInsets.all(6), + child: Column( + children: [ + CodeCard(code: 'final value = compute(input); // ' * 4), + const BountyCard(resolved: false, price: '100'), + const QuotedText('a quoted reply that is long enough to wrap on a phone'), + ], + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(CodeCard), findsOneWidget); + expect(find.textContaining('a quoted reply'), findsOneWidget); + }); + + testWidgets('rate score chips keep the sign of the value', (tester) async { + await _pumpNarrow( + tester, + const Wrap( + children: [ + RateScoreChip(name: 'A', value: 3), + RateScoreChip(name: 'B', value: -2), + RateScoreChip(name: 'C', value: 0), + ], + ), + scale: 1, + ); + expect(tester.takeException(), isNull); + expect(find.byType(RateScoreChip), findsNWidgets(3)); + expect( + tester.widgetList(find.byType(RichText)).map((e) => e.text.toPlainText()), + containsAll(['A +3', 'B -2', 'C 0']), + ); + }); + + testWidgets('a blocked floor keeps its number and one action on a narrow phone', (tester) async { + await _pumpNarrow( + tester, + const Padding( + padding: EdgeInsets.all(6), + child: BlockedPostPlaceholder(uid: 1001, username: 'Bob', floor: 42), + ), + ); + expect(tester.takeException(), isNull); + expect(find.text('#42'), findsOneWidget); + expect(find.byType(TextButton), findsOneWidget); + }); + }); +} diff --git a/test/regression/test_166_full_ui_phase4_test.dart b/test/regression/test_166_full_ui_phase4_test.dart new file mode 100644 index 000000000..cfffd7a91 --- /dev/null +++ b/test/regression/test_166_full_ui_phase4_test.dart @@ -0,0 +1,229 @@ +/// Full UI redesign, phase 4: shop, bank and community surfaces on narrow screens, large fonts and wide windows. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/bank/cubit/bank_cubit.dart'; +import 'package:tsdm_client/features/bank/models/bank_data.dart'; +import 'package:tsdm_client/features/bank/repository/bank_repository.dart'; +import 'package:tsdm_client/features/bank/view/bank_page.dart'; +import 'package:tsdm_client/features/checkin/widgets/auto_checkin_user_card.dart'; +import 'package:tsdm_client/features/title_shop/cubit/title_shop_cubit.dart'; +import 'package:tsdm_client/features/title_shop/repository/title_shop_repository.dart'; +import 'package:tsdm_client/features/title_shop/view/title_shop_page.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:universal_html/parsing.dart'; + +import 'fixtures/title_shop_fixtures.dart'; + +const _longName = '很长的虚构作品名称很长的虚构作品名称很长的虚构作品名称'; + +TitleShopCubit _shop(List> posts) => TitleShopCubit( + currentUid: () => 1000, + repository: () => TitleShopRepository( + getPage: (_) async => shopPage([ + buyRow(9001, '虚构作品-甲', '3800'), + ownedRow(9002, '虚构作品-乙', '4500'), + buyRow(9003, _longName, '4294967295'), + ]), + postForm: (_, body) async { + posts.add(body); + return messagePage('操作完成'); + }, + ), +); + +const _bank = ForumBank(id: 1, name: 'Synthetic bank', hasAccount: true); + +// Synthetic markup, the same shape as the bank page tests use; no live account data. +BankSavings _savings() => parseBankSavings( + parseHtmlDocument(''' +
  • Test coins:800(银行货币)
+ + +

活期储蓄

您当前的存款金额为 100 ,活期利率为1‰。
+ + + + + + +
+'''), + bankId: 1, +); + +class _BankRepository extends BankRepository { + _BankRepository() : super(getPage: (_) async => '', postForm: (_, _) async => ''); + + final submissions = []; + + @override + Future fetchDirectory(int uid) async => const BankDirectory(banks: [_bank]); + + @override + Future fetchSavings(int bankId, int uid) async => _savings(); + + @override + Future fetchLogs(int bankId, int uid, {bool received = false, int page = 1}) async => BankLogs( + entries: [ + BankLogEntry(message: 'Synthetic transaction record with a rather long description $page', time: '2026-01-01'), + ], + hasNext: page == 1, + ); + + @override + Future submit(BankTransactionForm form, BankOperation operation, String amount, String password) async { + submissions.add(amount); + } +} + +Future _pump(WidgetTester tester, Widget home, {required Size size, double scale = 1}) async { + await LocaleSettings.setLocale(AppLocale.en); + tester.view + ..physicalSize = size + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: home, + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + group('title shop', () { + testWidgets('320 wide with 2x text: artwork keeps the 184x100 ratio and the confirmation keeps the price', ( + tester, + ) async { + final posts = >[]; + final cubit = _shop(posts); + addTearDown(cubit.close); + await _pump( + tester, + TitleShopPage( + controller: cubit, + imageBuilder: (_) => const ColoredBox(key: ValueKey('artwork'), color: Colors.grey), + ), + size: const Size(320, 640), + scale: 2, + ); + expect(tester.takeException(), isNull); + final artwork = tester.getSize(find.byKey(const ValueKey('artwork')).first); + expect(artwork.width / artwork.height, closeTo(184 / 100, 0.01), reason: 'contained, never cropped'); + expect(artwork.width, lessThanOrEqualTo(184)); + + final buy = find.descendant( + of: find.byKey(const ValueKey('title-9003')), + matching: find.widgetWithText(FilledButton, t.titleShop.purchase), + ); + await tester.scrollUntilVisible(buy.hitTestable(), 200); + await tester.pumpAndSettle(); + await tester.tap(buy); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + final dialog = find.byType(AlertDialog); + expect(find.descendant(of: dialog, matching: find.text(shopConfirm('4294967295'))), findsOneWidget); + expect( + find.descendant( + of: dialog, + matching: find.text(t.titleShop.idAndPrice(id: 9003, price: '4294967295')), + ), + findsOneWidget, + reason: 'the full price is shown, not shortened', + ); + expect(find.descendant(of: dialog, matching: find.text(t.titleShop.notEquipped)), findsOneWidget); + await tester.ensureVisible(find.text(t.general.cancel)); + await tester.tap(find.text(t.general.cancel)); + await tester.pumpAndSettle(); + expect(posts, isEmpty); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a wide window shows the titles in two columns', (tester) async { + final cubit = _shop([]); + addTearDown(cubit.close); + await _pump( + tester, + TitleShopPage(controller: cubit, imageBuilder: (_) => const SizedBox()), + size: const Size(1280, 800), + ); + final first = tester.getRect(find.byKey(const ValueKey('title-9001'))); + final second = tester.getRect(find.byKey(const ValueKey('title-9002'))); + expect(second.top, first.top); + expect(second.left, greaterThan(first.right)); + expect(first.width, lessThanOrEqualTo(480), reason: 'the list is centered at most 960 wide'); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + }); + + testWidgets('bank: 320 wide with 2x text keeps savings, records and the confirmation readable', (tester) async { + await LocaleSettings.setLocale(AppLocale.en); + final repository = _BankRepository(); + final cubit = BankCubit(currentUid: () => 1000, repository: () => repository); + addTearDown(cubit.close); + await _pump(tester, BankPage(controller: cubit), size: const Size(320, 640), scale: 2); + await tester.tap(find.byKey(const ValueKey('bank-1'))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + + final deposit = find.byKey(const ValueKey('bank-deposit')); + await tester.ensureVisible(deposit); + await tester.pumpAndSettle(); + await tester.tap(deposit); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('bank-amount')), '12'); + await tester.enterText(find.byKey(const ValueKey('bank-password')), 'synthetic-password'); + final next = find.byKey(const ValueKey('bank-continue')); + await tester.ensureVisible(next); + await tester.pumpAndSettle(); + await tester.tap(next); + await tester.pumpAndSettle(); + expect(find.text('Deposit: 12 Test coins'), findsOneWidget); + expect(tester.takeException(), isNull); + expect(repository.submissions, isEmpty, reason: 'reviewing is not confirming'); + await tester.tap(find.text(t.bank.cancel)); + await tester.pumpAndSettle(); + expect(repository.submissions, isEmpty); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('auto checkin card: long names and failures wrap at 320 wide with 2x text', (tester) async { + await _pump( + tester, + const Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + AutoCheckinUserCard( + UserLoginInfo(username: '一个非常非常长的虚构用户名称用于测试换行', uid: 123456789), + 'Synthetic failure message that is long enough to need several lines on a phone', + failure: true, + ), + AutoCheckinUserCard(UserLoginInfo(username: 'x', uid: 1), null), + ], + ), + ), + ), + size: const Size(320, 640), + scale: 2, + ); + expect(tester.takeException(), isNull); + expect(find.text('UID 123456789'), findsOneWidget); + expect(find.byIcon(Icons.error_outline), findsOneWidget); + expect(find.byIcon(Icons.schedule_outlined), findsOneWidget); + }); +} diff --git a/test/regression/test_167_full_ui_phase5_test.dart b/test/regression/test_167_full_ui_phase5_test.dart new file mode 100644 index 000000000..77c5c0fff --- /dev/null +++ b/test/regression/test_167_full_ui_phase5_test.dart @@ -0,0 +1,272 @@ +/// Full UI redesign, phase 5: settings dialogs, confirmations, backup dialogs and the settings groups on a 320 wide +/// phone with 2x text; the choices and results of the dialogs are unchanged. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/need_login/view/need_login_page.dart'; +import 'package:tsdm_client/features/settings/widgets/auto_clear_image_cache_duration_dialog.dart'; +import 'package:tsdm_client/features/settings/widgets/auto_sync_notice_dialog.dart'; +import 'package:tsdm_client/features/settings/widgets/backup_secrets_dialogs.dart'; +import 'package:tsdm_client/features/settings/widgets/font_scale_dialog.dart'; +import 'package:tsdm_client/features/settings/widgets/language_dialog.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/utils/show_dialog.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/section_list_tile.dart'; +import 'package:tsdm_client/widgets/section_switch_list_tile.dart'; + +/// Hosts [home] in a go_router app (the settings dialogs close with `context.pop`) at [size] and text [scale]. +Future _pump(WidgetTester tester, Widget home, {Size size = const Size(320, 640), double scale = 2}) async { + await LocaleSettings.setLocale(AppLocale.en); + tester.view + ..physicalSize = size + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final router = GoRouter( + routes: [GoRoute(path: '/', builder: (_, _) => home)], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp.router( + routerConfig: router, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// A page with one button opening a dialog through [open]. +Widget _opener(Future Function(BuildContext context) open) => Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton(onPressed: () => open(context), child: const Text('open')), + ), + ), +); + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + testWidgets('a destructive question fits 320 wide at 2x, keeps Cancel then Ok as text buttons and answers true', ( + tester, + ) async { + bool? answer; + await _pump( + tester, + _opener( + (context) async => answer = await showQuestionDialog( + context: context, + title: 'Remove this account from the device with a rather long title', + message: 'Synthetic confirmation message that wraps on a narrow phone.', + dangerous: true, + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byIcon(Icons.warning_amber_outlined), findsOneWidget, reason: 'destructive title tile'); + final buttons = tester.widgetList(find.byType(TextButton)).toList(); + expect(buttons, hasLength(2)); + await tester.tap(find.byType(TextButton).last); + await tester.pumpAndSettle(); + expect(answer, isTrue); + }); + + testWidgets('a long question title keeps its whole text and both answers reachable with the keyboard up', ( + tester, + ) async { + const title = 'Remove this account from the device with a rather long title'; + bool? answer = true; + await _pump( + tester, + _opener( + (context) async => answer = await showQuestionDialog( + context: context, + title: title, + message: 'Synthetic confirmation message that wraps on a narrow phone.', + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 200); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + // The title is not cut: the same text is still there, it scrolls in its own area. + expect(find.text(title), findsOneWidget); + final buttons = find.byType(TextButton); + expect(buttons, findsNWidgets(2)); + for (final button in buttons.evaluate()) { + final rect = tester.getRect(find.byWidget(button.widget)); + expect(rect.bottom, lessThanOrEqualTo(640 - 200), reason: 'answers stay above the keyboard'); + } + await tester.tap(buttons.first); + await tester.pumpAndSettle(); + expect(answer, isFalse); + }); + + testWidgets('the export backup dialog keeps both password fields usable at 320 wide, 2x text and the keyboard up', ( + tester, + ) async { + Object? result = 'unset'; + await _pump(tester, _opener((context) async => result = await showExportBackupDialog(context))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + // The security text of the settings row is repeated in the dialog, nothing about the backup is dropped. + expect(find.text(t.settingsPage.advancedSection.exportBackup.includeAccountsDetail), findsOneWidget); + await tester.ensureVisible(find.byType(Switch)); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.byType(TextFormField), findsNWidgets(2)); + await tester.enterText(find.byType(TextFormField).at(0), 'correct horse'); + await tester.enterText(find.byType(TextFormField).at(1), 'correct horse'); + await tester.ensureVisible(find.byType(FilledButton).last); + await tester.tap(find.byType(FilledButton).last); + await tester.pumpAndSettle(); + expect((result! as ExportBackupChoice).password, 'correct horse'); + }); + + testWidgets('the unlock dialog shows the wrong password error at 320 wide and 2x text', (tester) async { + await _pump(tester, _opener((context) async => showUnlockBackupDialog(context, wrongPassword: true))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text(t.settingsPage.advancedSection.importData.unlock.wrongPassword), findsOneWidget); + expect(find.text(t.settingsPage.advancedSection.importData.unlock.tip), findsOneWidget); + }); + + testWidgets('an auto sync duration that is not one of the choices opens on the 10 minutes default', (tester) async { + Object? result; + await _pump( + tester, + _opener( + (context) async => + result = await showDialog(context: context, builder: (_) => const AutoSyncNoticeDialog(7)), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + // The fallback used to be the value 600 used as an index, out of range. + expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(FilledButton, t.general.ok)); + await tester.pumpAndSettle(); + expect(result, 600); + }); + + testWidgets('an image cache duration that is not one of the choices opens on the 7 days default', (tester) async { + Object? result; + await _pump( + tester, + _opener( + (context) async => result = await showDialog( + context: context, + builder: (_) => const AutoClearImageCacheDurationDialog(5), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(FilledButton, t.general.ok)); + await tester.pumpAndSettle(); + expect(result, 3600 * 24 * 7); + }); + + testWidgets('the text scale dialog fits at 2x and returns the unchanged scale', (tester) async { + Object? result; + await _pump( + tester, + _opener( + (context) async => + result = await showDialog(context: context, builder: (_) => const TextScaleDialog(1.1)), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(FilledButton, t.general.ok)); + await tester.pumpAndSettle(); + expect(result, 1.1); + }); + + testWidgets('the language dialog returns the tapped locale', (tester) async { + Object? result; + await _pump( + tester, + _opener( + (context) async => + result = await showDialog<(AppLocale?, bool)>(context: context, builder: (_) => const LanguageDialog('en')), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('繁體中文')); + await tester.tap(find.text('繁體中文')); + await tester.pumpAndSettle(); + expect(result, (AppLocale.zhTw, false)); + }); + + testWidgets('a settings group with rows and a footer notice lays out at 320 wide and 2x text', (tester) async { + var value = false; + await _pump( + tester, + Scaffold( + body: StatefulBuilder( + builder: (context, setState) => ListView( + padding: const EdgeInsets.all(12), + children: [ + AppTileGroup( + title: 'Synthetic section with a long title', + icon: Icons.tune_outlined, + footer: const AppNoticeBanner(message: 'Synthetic limitation notice that wraps over several lines.'), + children: [ + const SectionListTile( + leading: Icon(Icons.info_outline), + title: Text('Row title'), + subtitle: Text('Row detail that is long enough to wrap'), + ), + SectionSwitchListTile( + title: const Text('Switch row'), + value: value, + onChanged: (v) => setState(() => value = v), + ), + ], + ), + ], + ), + ), + ), + ); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.byType(Switch)); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(value, isTrue); + }); + + testWidgets('the need login state shows one login action at 320 wide and 2x text', (tester) async { + await _pump(tester, NeedLoginPage(backUri: Uri.parse('/'), showAppBar: true)); + expect(tester.takeException(), isNull); + expect(find.text(t.general.needLoginToSeeThisPage), findsOneWidget); + // FilledButton.icon builds a FilledButton subclass: match by predicate, not by exact type. + final login = find.ancestor( + of: find.text(t.loginPage.login), + matching: find.byWidgetPredicate((w) => w is FilledButton), + ); + expect(login, findsOneWidget); + }); +} diff --git a/test/regression/test_168_navigation_refinement_test.dart b/test/regression/test_168_navigation_refinement_test.dart new file mode 100644 index 000000000..0ff7226f8 --- /dev/null +++ b/test/regression/test_168_navigation_refinement_test.dart @@ -0,0 +1,719 @@ +import 'dart:async'; + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/home/cubit/home_cubit.dart'; +import 'package:tsdm_client/features/home/widgets/widgets.dart'; +import 'package:tsdm_client/features/homepage/widgets/home_dashboard.dart'; +import 'package:tsdm_client/features/local_notice/tap.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_state_cubit.dart'; +import 'package:tsdm_client/features/notification/repository/notification_info_repository.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; +import 'package:tsdm_client/features/profile/models/secondary_title.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/features/red_packet/models/models.dart'; +import 'package:tsdm_client/features/red_packet/widgets/daily_red_packet_button.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; + +/// Navigation refinement of 2026-09-27: +/// +/// * the side navigation (drawer on large windows, rail on medium ones) has nine real entries in two groups; the tabs +/// keep their shell state and double tap, the other entries open their page above the shell and the back button +/// returns to the selected tab; the phone bar keeps its three tabs; +/// * the notifications entry shows the real unread count by the rules of the app bar notice icon; +/// * the homepage greeting always shows a daily red packet entry with an honest state and submits at most once; +/// * the title badge of the greeting is 240px wide on wide layouts and 160 to 184px on phones. +/// +/// Synthetic accounts and answers only: no network, nothing is claimed, no login. +Translations get tr => LocaleSettings.instance.currentTranslations; + +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); + +/// Pages opened above the shell by the side navigation, and the login page. +const _pushedPaths = [ + ScreenPaths.activities, + ScreenPaths.notice, + ScreenPaths.loggedUserProfile, + ScreenPaths.titleShop, + ScreenPaths.medalCenter, + ScreenPaths.bank, + ScreenPaths.login, +]; + +const _tabPaths = [ScreenPaths.homepage, ScreenPaths.topic, '/settings']; + +enum _Layout { phone, tablet, desktop } + +const _config = DailyRedPacketConfig(entry: 2, dateFlag: '20260927', from: 'System', bless: 'Hi', unit: 'coins'); + +void main() { + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.en); + }); + + group('side navigation', () { + late HomeCubit cubit; + late GoRouter router; + late DateTime now; + + setUp(() { + now = DateTime(2026, 9, 27, 12); + HomeTabDoubleTapDetector.clock = () => now; + }); + + tearDown(() => HomeTabDoubleTapDetector.clock = DateTime.now); + + String? location() => routerTopLocation(router); + + Future pumpHome( + WidgetTester tester, + _Layout layout, { + AuthenticationRepository? auth, + NotificationStateCubit? notice, + }) async { + cubit = HomeCubit(); + router = GoRouter( + initialLocation: ScreenPaths.homepage, + routes: [ + ShellRoute( + builder: (context, state, child) => switch (layout) { + _Layout.phone => Scaffold(body: child, bottomNavigationBar: const HomeNavigationBar()), + _Layout.tablet => Scaffold( + body: Row( + children: [ + const HomeNavigationRail(), + Expanded(child: child), + ], + ), + ), + // Same constraint as HomePage puts on the drawer. + _Layout.desktop => Scaffold( + body: Row( + children: [ + const SizedBox(width: 250, child: HomeNavigationDrawer()), + Expanded(child: child), + ], + ), + ), + }, + routes: [ + for (final path in _tabPaths) + GoRoute( + path: path, + name: path, + pageBuilder: (context, state) => NoTransitionPage(child: Center(child: Text('tab $path'))), + ), + ], + ), + for (final path in _pushedPaths) + GoRoute( + path: path, + name: path, + builder: (context, state) => Scaffold( + appBar: AppBar(), + body: Text('pushed $path'), + ), + ), + ], + ); + addTearDown(router.dispose); + addTearDown(cubit.close); + Widget app = BlocProvider.value( + value: cubit, + child: MaterialApp.router(routerConfig: router), + ); + if (notice != null) { + app = BlocProvider.value(value: notice, child: app); + } + if (auth != null) { + app = RepositoryProvider.value(value: auth, child: app); + } + await tester.pumpWidget(TranslationProvider(child: app)); + await tester.pump(); + } + + /// Labels of the nine entries in display order. + List labels() => [ + tr.navigation.homepage, + tr.navigation.topics, + tr.navigation.activities, + tr.navigation.notice, + tr.navigation.profile, + tr.navigation.titleShop, + tr.navigation.medalCenter, + tr.navigation.bank, + tr.navigation.settings, + ]; + + int drawerSelected(WidgetTester tester) => + tester.widget(find.byType(NavigationDrawer)).selectedIndex!; + + testWidgets('drawer: nine entries in two groups, in the order of the reference', (tester) async { + tester.view + ..physicalSize = const Size(1280, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpHome(tester, _Layout.desktop); + + expect(tr.navigation.topics, 'Forums'); + final drawer = find.byType(HomeNavigationDrawer); + var previous = double.negativeInfinity; + for (final label in labels()) { + final text = find.descendant(of: drawer, matching: find.text(label)); + expect(text, findsOneWidget, reason: label); + final y = tester.getTopLeft(text).dy; + expect(y, greaterThan(previous), reason: '$label comes after the previous entry'); + previous = y; + } + final header = find.descendant(of: drawer, matching: find.text(tr.navigation.moreSection)); + expect(header, findsOneWidget); + expect(tester.getTopLeft(header).dy, greaterThan(tester.getTopLeft(find.text(tr.navigation.profile)).dy)); + expect(tester.getTopLeft(header).dy, lessThan(tester.getTopLeft(find.text(tr.navigation.titleShop)).dy)); + expect(drawerSelected(tester), 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('drawer: every page entry opens its real route, back returns to the selected tab', (tester) async { + tester.view + ..physicalSize = const Size(1280, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final auth = AuthenticationRepository(user: _alice); + addTearDown(auth.dispose); + await pumpHome(tester, _Layout.desktop, auth: auth); + + // Start on the forums tab so going back has to restore a tab other than the first one. + await tester.tap(find.text(tr.navigation.topics)); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.topic); + expect(drawerSelected(tester), 1); + + final pages = { + tr.navigation.activities: ScreenPaths.activities, + tr.navigation.notice: ScreenPaths.notice, + tr.navigation.profile: ScreenPaths.loggedUserProfile, + tr.navigation.titleShop: ScreenPaths.titleShop, + tr.navigation.medalCenter: ScreenPaths.medalCenter, + tr.navigation.bank: ScreenPaths.bank, + }; + for (final MapEntry(key: label, value: path) in pages.entries) { + now = now.add(const Duration(seconds: 1)); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(location(), path, reason: label); + expect(find.text('pushed $path'), findsOneWidget, reason: label); + expect(cubit.state.tab, HomeTab.topic, reason: 'a page entry is not a tab'); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.topic, reason: 'back from $label'); + expect(find.text('tab ${ScreenPaths.topic}'), findsOneWidget); + expect(drawerSelected(tester), 1, reason: 'the forums tab stays selected after $label'); + } + + // Settings is a tab of the "more" group: selected at its own index, the tab of the cubit follows. + now = now.add(const Duration(seconds: 1)); + await tester.tap(find.text(tr.navigation.settings)); + await tester.pumpAndSettle(); + expect(location(), '/settings'); + expect(cubit.state.tab, HomeTab.settings); + expect(drawerSelected(tester), 8); + expect(tester.takeException(), isNull); + }); + + testWidgets('drawer: a quick double tap on a page entry opens one page', (tester) async { + tester.view + ..physicalSize = const Size(1280, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpHome(tester, _Layout.desktop); + + await tester.tap(find.byIcon(Icons.account_balance_outlined)); + now = now.add(const Duration(milliseconds: 100)); + await tester.tap(find.byIcon(Icons.account_balance_outlined), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(find.text('pushed ${ScreenPaths.bank}'), findsOneWidget); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.homepage, reason: 'only one page was on top of the shell'); + expect(find.byType(BackButton), findsNothing); + }); + + testWidgets('notifications without an account open the login page', (tester) async { + tester.view + ..physicalSize = const Size(1280, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final auth = AuthenticationRepository(); + addTearDown(auth.dispose); + await pumpHome(tester, _Layout.desktop, auth: auth); + + await tester.tap(find.text(tr.navigation.notice)); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.login); + }); + + testWidgets('rail: nine entries, scrolls in a short window with 2x text, the bank entry opens the bank', ( + tester, + ) async { + tester.view + ..physicalSize = const Size(700, 400) + ..devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = 2; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + await pumpHome(tester, _Layout.tablet); + + expect(tester.takeException(), isNull, reason: 'no vertical overflow'); + final rail = tester.widget(find.byType(NavigationRail)); + expect(rail.destinations, hasLength(9)); + expect(rail.selectedIndex, 0); + + final bank = find.byIcon(Icons.account_balance_outlined); + await tester.dragUntilVisible(bank, find.byType(HomeNavigationRail), const Offset(0, -80)); + await tester.pumpAndSettle(); + await tester.tap(bank); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.bank); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.homepage); + expect(tester.widget(find.byType(NavigationRail)).selectedIndex, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('phone: the bottom bar keeps its three tabs and still switches them', (tester) async { + tester.view + ..physicalSize = const Size(390, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + await pumpHome(tester, _Layout.phone); + + final bar = tester.widget(find.byType(NavigationBar)); + expect(bar.destinations, hasLength(3)); + expect(find.text(tr.navigation.bank), findsNothing); + expect(find.text(tr.navigation.activities), findsNothing); + + await tester.tap(find.text(tr.navigation.topics)); + await tester.pumpAndSettle(); + expect(location(), ScreenPaths.topic); + expect(tester.widget(find.byType(NavigationBar)).selectedIndex, 1); + now = now.add(const Duration(seconds: 1)); + await tester.tap(find.text(tr.navigation.settings)); + await tester.pumpAndSettle(); + expect(location(), '/settings'); + expect(tester.widget(find.byType(NavigationBar)).selectedIndex, 2); + expect(tester.takeException(), isNull); + }); + + group('unread count on the notifications entry', () { + late AppDatabase db; + late SettingsRepository settings; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + final storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings); + await settings.init(); + }); + + tearDown(() async { + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + Future unread(int notice, int pm, int broadcast) async { + final info = NotificationInfoRepository(); + final cubit = NotificationStateCubit(info) + ..setAll(noticeCount: notice, personalMessageCount: pm, broadcastMessageCount: broadcast); + addTearDown(() async { + await cubit.close(); + await info.dispose(); + }); + return cubit; + } + + Finder countIn(int count) => + find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.text('$count')); + + Future pumpDesktop( + WidgetTester tester, { + required UserLoginInfo? user, + required bool hint, + int notices = 2, + int messages = 1, + }) async { + // The database is only used from the real zone: drift timers left in the fake zone never run. + await tester.runAsync(() => settings.setValue(SettingsKeys.showUnreadInfoHint, hint)); + expect(settings.currentSettings.showUnreadInfoHint, hint); + tester.view + ..physicalSize = const Size(1280, 800) + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final auth = AuthenticationRepository(user: user); + addTearDown(auth.dispose); + await pumpHome(tester, _Layout.desktop, auth: auth, notice: await unread(notices, messages, 0)); + } + + /// Let the drawer show a new state of the cubit. A cubit emits on a broadcast stream, so `BlocBuilder` hears it + /// in a microtask. `pump()` without a scheduled frame only flushes microtasks at its end: the listener marks the + /// builder dirty and schedules a frame then, which the next `pump()` draws. + Future showState(WidgetTester tester) async { + await tester.pump(); + await tester.pump(); + } + + testWidgets('the real total of the notification state, with an account and the hint on', (tester) async { + await pumpDesktop(tester, user: _alice, hint: true); + expect(countIn(3), findsOneWidget); + expect(find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.byType(Badge)), findsOneWidget); + + // Follows the state: read notices lower the count, nothing unread hides the badge. + final notice = BlocProvider.of(tester.element(find.byType(HomeNavigationDrawer))) + ..decreaseNotice(2); + expect(notice.state.total, 1); + await showState(tester); + expect(countIn(1), findsOneWidget); + expect(countIn(3), findsNothing, reason: 'the old count is gone'); + expect(find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.byType(Badge)), findsOneWidget); + notice.decreasePersonalMessage(); + expect(notice.state.total, 0); + await showState(tester); + expect(find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.byType(Badge)), findsNothing); + expect(countIn(1), findsNothing); + }); + + testWidgets('no badge when the unread hint setting is off', (tester) async { + await pumpDesktop(tester, user: _alice, hint: false); + expect(find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.byType(Badge)), findsNothing); + expect(countIn(3), findsNothing); + }); + + testWidgets('no badge without an account', (tester) async { + await pumpDesktop(tester, user: null, hint: true); + expect(find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.byType(Badge)), findsNothing); + }); + + testWidgets('no badge when nothing is unread', (tester) async { + await pumpDesktop(tester, user: _alice, hint: true, notices: 0, messages: 0); + expect(find.descendant(of: find.byType(HomeNavigationDrawer), matching: find.byType(Badge)), findsNothing); + expect(countIn(0), findsNothing, reason: 'no "0" badge'); + }); + }); + }); + + group('daily red packet entry', () { + Future pumpEntry(WidgetTester tester, Widget entry) async { + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: Scaffold(body: Center(child: entry)), + ), + ), + ); + await tester.pump(); + } + + Finder filledButtons() => find.byWidgetPredicate((w) => w is FilledButton); + + bool enabled(WidgetTester tester, Finder button) => tester.widget(button).onPressed != null; + + Future dispose(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(seconds: 5)); + } + + testWidgets('claimable: a double tap sends one request, then the claimed state never submits again', ( + tester, + ) async { + final calls = []; + final answer = Completer>(); + AsyncEither claim(String hash) { + calls.add(hash); + return TaskEither(() => answer.future); + } + + await pumpEntry( + tester, + DailyRedPacketEntry(uid: 1000, config: _config, formHash: 'hash', onCheck: () {}, claim: claim), + ); + final button = find.ancestor(of: find.text(tr.redPacket.daily.tooltip), matching: filledButtons()); + expect(button, findsOneWidget); + expect(enabled(tester, button), isTrue); + + await tester.tap(button); + await tester.tap(button, warnIfMissed: false); + await tester.pump(); + expect(calls, ['hash'], reason: 'one request for two quick taps'); + expect(enabled(tester, filledButtons()), isFalse, reason: 'disabled while the request runs'); + + answer.complete(const Right(DailyRedPacketResult(ok: true, amount: '5', unit: 'coins'))); + await tester.pump(); + await tester.pump(); + final claimed = find.ancestor(of: find.text(tr.redPacket.daily.claimedToday), matching: filledButtons()); + expect(claimed, findsOneWidget); + expect(enabled(tester, claimed), isFalse); + + await tester.tap(claimed, warnIfMissed: false); + await tester.pump(); + expect(calls, hasLength(1)); + + // Another account on the same page starts a new button: nothing of the claim above carries over. + await pumpEntry( + tester, + DailyRedPacketEntry(uid: 2000, config: _config, formHash: 'hash', onCheck: () {}, claim: claim), + ); + expect(find.text(tr.redPacket.daily.claimedToday), findsNothing, reason: 'another account claimed nothing'); + expect( + enabled(tester, find.ancestor(of: find.text(tr.redPacket.daily.tooltip), matching: filledButtons())), + isTrue, + ); + expect(calls, hasLength(1)); + await dispose(tester); + }); + + testWidgets('"already claimed" from the server is the claimed state too', (tester) async { + var calls = 0; + await pumpEntry( + tester, + DailyRedPacketEntry( + uid: 1000, + config: _config, + formHash: 'hash', + onCheck: () {}, + claim: (_) { + calls++; + return TaskEither.right(const DailyRedPacketResult(ok: false, already: true)); + }, + ), + ); + await tester.tap(find.text(tr.redPacket.daily.tooltip)); + await tester.pump(); + await tester.pump(); + expect(calls, 1); + expect(find.text(tr.redPacket.daily.claimedToday), findsOneWidget); + await dispose(tester); + }); + + testWidgets('a failed request keeps the packet claimable and records nothing', (tester) async { + var calls = 0; + await pumpEntry( + tester, + DailyRedPacketEntry( + uid: 1000, + config: _config, + formHash: 'hash', + onCheck: () {}, + claim: (_) { + calls++; + return TaskEither.left(HttpRequestFailedException(500)); + }, + ), + ); + await tester.tap(find.text(tr.redPacket.daily.tooltip)); + await tester.pump(); + await tester.pump(); + expect(calls, 1); + expect(find.text(tr.redPacket.daily.claimedToday), findsNothing); + final button = find.ancestor(of: find.text(tr.redPacket.daily.tooltip), matching: filledButtons()); + expect(enabled(tester, button), isTrue, reason: 'the user can try again'); + await dispose(tester); + }); + + testWidgets('no packet on the homepage: "none now" with a packet check, never "claimed"', (tester) async { + var refreshes = 0; + await pumpEntry( + tester, + DailyRedPacketEntry(uid: 1000, config: null, formHash: 'hash', onCheck: () => refreshes++), + ); + expect(find.text(tr.redPacket.daily.unavailable), findsOneWidget); + expect(find.text(tr.redPacket.daily.claimedToday), findsNothing); + expect(find.text(tr.redPacket.daily.tooltip), findsNothing, reason: 'nothing claimable is made up'); + await tester.tap(find.text(tr.redPacket.daily.unavailable)); + await tester.pump(); + expect(refreshes, 1); + + // A packet without a form hash cannot be claimed either. + await pumpEntry(tester, DailyRedPacketEntry(uid: 1000, config: _config, formHash: '', onCheck: () {})); + expect(find.text(tr.redPacket.daily.unavailable), findsOneWidget); + await dispose(tester); + }); + + testWidgets('a reloaded page without a packet after a confirmed claim: none to claim, no date guessed', ( + tester, + ) async { + var refreshes = 0; + await pumpEntry( + tester, + DailyRedPacketEntry( + uid: 1000, + config: _config, + formHash: 'hash', + onCheck: () => refreshes++, + claim: (_) => TaskEither.right(const DailyRedPacketResult(ok: true, amount: '5', unit: 'coins')), + ), + ); + await tester.tap(find.text(tr.redPacket.daily.tooltip)); + await tester.pump(); + await tester.pump(); + expect(find.text(tr.redPacket.daily.claimedToday), findsOneWidget); + + // The app knows no clock of the forum: without a packet on the page it cannot tell which day it is there, so it + // does not say "claimed today"; the hint says it may be claimed already. + await pumpEntry( + tester, + DailyRedPacketEntry(uid: 1000, config: null, formHash: null, onCheck: () => refreshes++), + ); + expect(find.text(tr.redPacket.daily.claimedToday), findsNothing); + expect(find.text(tr.redPacket.daily.unavailable), findsOneWidget); + expect(find.byTooltip(tr.redPacket.daily.unavailableHint), findsOneWidget); + await tester.tap(find.text(tr.redPacket.daily.unavailable)); + await tester.pump(); + expect(refreshes, 1); + await dispose(tester); + }); + + testWidgets('without an account: says a login is needed', (tester) async { + await pumpEntry(tester, DailyRedPacketEntry(uid: null, config: _config, formHash: 'hash', onCheck: () {})); + expect(find.text(tr.redPacket.daily.needLogin), findsOneWidget); + expect(find.text(tr.redPacket.daily.tooltip), findsNothing); + await dispose(tester); + }); + }); + + group('greeting title badge', () { + late StreamController auth; + late CurrentTitleCubit cubit; + late List>>> pending; + + setUp(() { + auth = StreamController.broadcast(sync: true); + pending = []; + cubit = CurrentTitleCubit( + currentUid: () => _alice.uid, + authStatus: auth.stream, + fetchTitles: () => TaskEither(() { + final completer = Completer>>(); + pending.add(completer); + return completer.future; + }), + ); + auth.add(const AuthStatusAuthed(_alice)); + }); + + tearDown(() async { + await cubit.close(); + await auth.close(); + }); + + /// The greeting header in a [window], padded as in the card; [compact] for phones, [scale] for the text. + Future pumpHeader( + WidgetTester tester, { + required Size window, + required bool compact, + double scale = 1, + }) async { + tester.view + ..physicalSize = window + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + // Page padding 12 and card padding 16 (phones) or 24 (wide) on each side, as on the homepage. + final padding = compact ? 28.0 : 36.0; + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: Scaffold( + body: BlocProvider.value( + value: cubit, + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: padding, vertical: 16), + child: HomeGreetingHeader( + greeting: const Text('Good evening, Alice with a rather long name', key: ValueKey('greeting')), + uid: _alice.uid, + compact: compact, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + /// The title is read: the real badge widget. The workaround url is drawn without the image cache, so the box of the + /// badge is measured without network or database. + Future completeTitle(WidgetTester tester) async { + pending.single.complete( + Right([SecondaryTitle(id: 1, name: 'T', imageUrl: tmpImpellerWorkaroundUrls.first, activated: true)]), + ); + await tester.pump(); + await tester.pump(); + } + + testWidgets('desktop: 240px wide, complete 184:100 box, beside the greeting', (tester) async { + await pumpHeader(tester, window: const Size(1000, 600), compact: false); + final expected = Size(240, SecondaryTitleBadge.heightFor(240)); + expect(tester.getSize(find.byType(SecondaryTitlePlaceholder)), expected, reason: 'room kept while loading'); + + await completeTitle(tester); + final badge = find.byType(SecondaryTitleBadge); + expect(badge, findsOneWidget); + expect(tester.getSize(badge), expected); + expect(tester.getSize(badge).width / tester.getSize(badge).height, closeTo(184 / 100, 0.01)); + final greeting = tester.getRect(find.byKey(const ValueKey('greeting'))); + expect(tester.getRect(badge).left, greaterThan(greeting.right), reason: 'beside the greeting'); + expect(tester.getRect(badge).right, lessThanOrEqualTo(1000 - 36)); + expect(tester.takeException(), isNull); + }); + + testWidgets('phone 412: 160 to 184px beside the greeting', (tester) async { + await pumpHeader(tester, window: const Size(412, 800), compact: true); + await completeTitle(tester); + final size = tester.getSize(find.byType(SecondaryTitleBadge)); + expect(size.width, inInclusiveRange(160, 184)); + expect(size.height, closeTo(SecondaryTitleBadge.heightFor(size.width), 0.01)); + final greeting = tester.getRect(find.byKey(const ValueKey('greeting'))); + expect(tester.getRect(find.byType(SecondaryTitleBadge)).left, greaterThan(greeting.right)); + expect(tester.takeException(), isNull); + }); + + testWidgets('phone 320 with 2x text: 160px below the greeting, nothing overflows', (tester) async { + await pumpHeader(tester, window: const Size(320, 640), compact: true, scale: 2); + await completeTitle(tester); + expect(tester.takeException(), isNull); + final badge = tester.getRect(find.byType(SecondaryTitleBadge)); + expect(badge.width, 160); + expect(badge.height, closeTo(SecondaryTitleBadge.heightFor(160), 0.01)); + final greeting = tester.getRect(find.byKey(const ValueKey('greeting'))); + expect(badge.top, greaterThanOrEqualTo(greeting.bottom), reason: 'wrapped below the greeting'); + expect(badge.right, lessThanOrEqualTo(320 - 28)); + expect(badge.left, greeting.left, reason: 'the badge starts under the greeting, which keeps the whole width'); + expect(find.text('Good evening, Alice with a rather long name'), findsOneWidget); + }); + }); +} diff --git a/test/regression/test_169_topics_desktop_test.dart b/test/regression/test_169_topics_desktop_test.dart new file mode 100644 index 000000000..a17d33382 --- /dev/null +++ b/test/regression/test_169_topics_desktop_test.dart @@ -0,0 +1,423 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/favorite/repository/favorite_repository.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; +import 'package:tsdm_client/features/topics/view/topics_page.dart'; +import 'package:tsdm_client/features/topics/widgets/group_moderators_row.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; +import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart'; +import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/card/forum_card.dart'; +import 'package:tsdm_client/widgets/network_indicator_image.dart'; + +/// preview106 feedback: on a desktop window the topics page used the shared 960px list width, so four small forum +/// cards sat in the middle of a wide window. The page now widens to [topicsPageMaxWidth] (still two columns) and shows +/// large cards once it really has [topicsPageLargeCardWidth]; phones and medium windows keep the compact layout. +/// UI107 feedback: the large cards were still mostly empty, so the picture (up to 240x120) and a 24px name now share +/// the card width and the counters are three equal blocks filling the bottom; the page grows to 1520. +/// preview109 feedback: phones must not use the desktop size, so large cards are for desktop platforms only: desktop +/// cases run as Windows, phone cases as Android, and a 1600 wide Android landscape stays compact. +/// +/// Fake data only (same fake index shape as test_043): no forum icon, so no image is fetched. +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); + +/// Desktop window. +const _windows = TargetPlatformVariant({TargetPlatform.windows}); + +/// Phone. +const _android = TargetPlatformVariant({TargetPlatform.android}); + +/// Serves the fake forum index for every `/forum.php` request. +final class _FakeAdapter implements HttpClientAdapter { + _FakeAdapter(this.index); + + final String Function() index; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + if (requestStream != null) { + await requestStream.fold>([], (a, b) => a..addAll(b)); + } + if (options.uri.path != '/forum.php') { + return ResponseBody.fromString('not found', 404); + } + return ResponseBody.fromString( + index(), + 200, + headers: { + Headers.contentTypeHeader: ['text/html; charset=utf-8'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +final class _FakeAuth extends AuthenticationRepository { + final subject = BehaviorSubject(); + + @override + UserLoginInfo? get currentUser => _alice; + + @override + Stream get status => subject.stream; + + @override + Future dispose() async { + await subject.close(); + await super.dispose(); + } +} + +typedef _Counts = ({String threads, String replies}); + +const _Counts _defaultCounts = (threads: '228862', replies: '10967904'); + +String _forumRow(int fid, String name, _Counts counts) => + ' ' + '

$name

' + '${counts.threads} ' + ' / ${counts.replies}'; + +/// One group with [moderators] in its header and [forums] as fid 1000, 1001..., each with the thread and reply +/// [counts]. +String _index( + List forums, { + _Counts counts = _defaultCounts, + List moderators = const ['琴吹紬', '捕风巫', 'cu', '未梓林'], +}) { + final mods = moderators.map((e) => '$e').join(', '); + return '
' + '

Alice

' + '
' + '
分区版主: $mods' + '

天使·后花园

' + '
' + '${forums.indexed.map((f) => _forumRow(1000 + f.$1, f.$2, counts)).join()}' + '
' + '
'; +} + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + late AppDatabase db; + late SettingsRepository settings; + late ForumHomeRepository forumHome; + late FavoriteRepository favorites; + late _FakeAuth auth; + late FragmentsRepository fragments; + var forums = ['新人报到', '活动专区', '若闲小阁', '天使名册']; + var counts = _defaultCounts; + + setUp(() async { + forums = ['新人报到', '活动专区', '若闲小阁', '天使名册']; + counts = _defaultCounts; + db = AppDatabase(NativeDatabase.memory()); + final storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + final adapter = _FakeAdapter(() => _index(forums, counts: counts)); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerSingleton(NetErrorSaver()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerFactory( + () => NetClientProvider.build(dio: Dio(BaseOptions(baseUrl: baseUrl))..httpClientAdapter = adapter), + ); + await settings.init(); + getIt.registerSingleton(ImageCacheProvider(getIt.get())); + forumHome = ForumHomeRepository(); + favorites = FavoriteRepository(); + auth = _FakeAuth(); + fragments = FragmentsRepository(); + }); + tearDown(() async { + await forumHome.dispose(); + await favorites.dispose(); + await auth.dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + /// `pumpAndSettle` never returns here because a refresh indicator keeps animating. + Future pumpFrames(WidgetTester tester) async { + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + GoRouter router() => GoRouter( + initialLocation: ScreenPaths.topic, + routes: [ + GoRoute(path: ScreenPaths.topic, name: ScreenPaths.topic, builder: (_, _) => const TopicsPage()), + GoRoute( + path: ScreenPaths.forum, + name: ScreenPaths.forum, + builder: (_, state) => Text('forum ${state.pathParameters['fid']}'), + ), + GoRoute( + path: ScreenPaths.profile, + name: ScreenPaths.profile, + builder: (_, state) => Text('profile ${state.uri.queryParameters['username']}'), + ), + ], + ); + + Future pumpTopics(WidgetTester tester, Size size, {double textScale = 1}) async { + tester.view + ..physicalSize = size + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + if (textScale != 1) { + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + final r = router(); + addTearDown(r.dispose); + await tester.pumpWidget( + TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: forumHome), + RepositoryProvider.value(value: auth), + RepositoryProvider.value(value: favorites), + RepositoryProvider.value(value: fragments), + ], + child: MaterialApp.router(routerConfig: r), + ), + ), + ); + await pumpFrames(tester); + return r; + } + + Finder card(int index) => find.byType(ForumCard).at(index); + Finder image(int index) => find.descendant(of: card(index), matching: find.byType(NetworkIndicatorImage)); + Size imageSize(WidgetTester tester, int index) => tester.getSize(image(index)); + Finder stats(int index) => find.descendant(of: card(index), matching: find.byType(ForumCardStat)); + + /// Width of the content of a large card (card minus its padding). + double contentWidth(WidgetTester tester, int index) => + tester.getSize(card(index)).width - forumCardLargePadding.horizontal; + + /// The three counters of a large card are equal blocks spanning the whole content width, on one line. + void expectStatsFillBottom(WidgetTester tester, int index) { + final cardRect = tester.getRect(card(index)); + expect(stats(index), findsNWidgets(3)); + expect(find.descendant(of: card(index), matching: find.byType(AppInfoPill)), findsNothing); + final rects = [for (var i = 0; i < 3; i++) tester.getRect(stats(index).at(i))]; + expect(rects.first.left, moreOrLessEquals(cardRect.left + forumCardLargePadding.left)); + expect(rects.last.right, moreOrLessEquals(cardRect.right - forumCardLargePadding.right)); + for (final r in rects) { + expect(r.width, moreOrLessEquals(rects.first.width)); + expect(r.top, moreOrLessEquals(rects.first.top)); + } + // Under the picture, at the bottom part of the card. + expect(rects.first.top, greaterThan(tester.getRect(image(index)).bottom)); + // Every counter keeps its explanation. + for (final s in tester.widgetList(stats(index))) { + expect(s.tooltip, endsWith(s.value)); + expect(s.caption, isNotEmpty); + expect(s.caption, isNot(contains(':'))); + } + } + + testWidgets('desktop: wider content, two columns of full large cards aligned with the moderators row', ( + tester, + ) async { + await pumpTopics(tester, const Size(1600, 1000)); + expect(tester.takeException(), isNull); + expect(find.byType(ForumCard), findsNWidgets(4)); + expect(tester.widgetList(find.byType(ForumCard)).every((e) => e.large), isTrue); + + // Content spans topicsPageMaxWidth (1520) centered in 1600, not the shared 960. + expect(topicsPageMaxWidth, 1520); + const side = (1600 - topicsPageMaxWidth) / 2; + final first = tester.getRect(card(0)); + final second = tester.getRect(card(1)); + expect(first.left, moreOrLessEquals(side)); + expect(second.right, moreOrLessEquals(1600 - side)); + expect(second.right - first.left, greaterThan(appListMaxWidth + 400)); + + // Still two columns (no grid of many small cards). + expect(second.top, moreOrLessEquals(first.top)); + expect(tester.getRect(card(2)).top, greaterThan(first.bottom)); + expect(first.width, moreOrLessEquals((topicsPageMaxWidth - appSurfaceGap) / 2)); + + // The moderators row has the same edges as the cards. + final mods = tester.getRect(find.byType(GroupModeratorsRow)); + expect(mods.left, moreOrLessEquals(first.left)); + expect(mods.right, moreOrLessEquals(second.right)); + expect(tester.widget(find.byType(GroupModeratorsRow)).large, isTrue); + + // Bigger content, not only a wider card: 240x120 picture beside a 24px name. + expect(forumCardLargeImageSize, const Size(240, 120)); + expect(forumCardLargeHeaderLayout(contentWidth(tester, 0), 1).sideBySide, isTrue); + expect(imageSize(tester, 0), forumCardLargeImageSize); + final picture = tester.getRect(image(0)); + expect(picture.left, moreOrLessEquals(first.left + forumCardLargePadding.left)); + final name = find.text('新人报到'); + expect(tester.widget(name).style?.fontSize, 24); + expect(tester.getRect(name).left, moreOrLessEquals(picture.right + forumCardLargeImageGap)); + + // Both columns line up: same picture size and top, same name start relative to the card. + expect(imageSize(tester, 1), imageSize(tester, 0)); + expect(tester.getRect(image(1)).top, moreOrLessEquals(picture.top)); + expect( + tester.getRect(find.text('活动专区')).left - second.left, + moreOrLessEquals(tester.getRect(name).left - first.left), + ); + + // Counters: three equal blocks filling the bottom, 20px icons, 20px numbers. + expectStatsFillBottom(tester, 0); + expectStatsFillBottom(tester, 1); + final firstStat = stats(0).first; + expect(tester.getSize(find.descendant(of: firstStat, matching: find.byType(Icon))).width, 20); + final value = tester.widget(firstStat).value; + expect(tester.widget(find.descendant(of: firstStat, matching: find.text(value))).style?.fontSize, 20); + expect(tester.getSize(firstStat).width, greaterThan(200)); + }, variant: _windows); + + testWidgets('Android 1600 wide landscape keeps the phone cards and the shared width', (tester) async { + await pumpTopics(tester, const Size(1600, 1000)); + expect(tester.takeException(), isNull); + expect(find.byType(ForumCard), findsNWidgets(4)); + expect(tester.widgetList(find.byType(ForumCard)).any((e) => e.large), isFalse); + final first = tester.getRect(card(0)); + final second = tester.getRect(card(1)); + expect(second.right - first.left, moreOrLessEquals(appListMaxWidth)); + expect(imageSize(tester, 0), forumCardImageSize); + expect(tester.widget(find.text('新人报到')).style?.fontSize, isNot(24)); + expect(tester.widget(find.byType(GroupModeratorsRow)).large, isFalse); + expect(find.descendant(of: card(0), matching: find.byType(AppInfoPill)), findsNWidgets(3)); + expect(find.byType(ForumCardStat), findsNothing); + }, variant: _android); + + testWidgets('a medium window keeps the compact cards and the shared width', (tester) async { + await pumpTopics(tester, const Size(1000, 800)); + expect(tester.takeException(), isNull); + expect(tester.widgetList(find.byType(ForumCard)).any((e) => e.large), isFalse); + final first = tester.getRect(card(0)); + final second = tester.getRect(card(1)); + expect(second.right - first.left, moreOrLessEquals(appListMaxWidth)); + expect(second.top, moreOrLessEquals(first.top), reason: 'two columns from appTwoColumnWidth, as before'); + expect(imageSize(tester, 0), forumCardImageSize); + expect(tester.widget(find.text('新人报到')).style?.fontSize, isNot(20)); + expect(tester.widget(find.byType(GroupModeratorsRow)).large, isFalse); + // Compact counters stay the small pills. + expect(find.descendant(of: card(0), matching: find.byType(AppInfoPill)), findsNWidgets(3)); + expect(find.byType(ForumCardStat), findsNothing); + }, variant: _windows); + + testWidgets('phone 320 at 2x text with long names: one compact column, no overflow while scrolling', (tester) async { + forums = [for (var i = 0; i < 6; i++) '很长很长的版块名称$i' * 4]; + await pumpTopics(tester, const Size(320, 640), textScale: 2); + expect(tester.takeException(), isNull); + expect(tester.widgetList(find.byType(ForumCard)).any((e) => e.large), isFalse); + // One column on phones: the card fills the page minus the phone side padding (12 each side). + expect(tester.getRect(card(0)).width, moreOrLessEquals(320 - 2 * appPagePadding(320))); + expect(imageSize(tester, 0), forumCardImageSize); + expect(find.byType(ForumCardStat), findsNothing); + final list = find.descendant(of: find.byType(TabBarView), matching: find.byType(ListView)).first; + for (var i = 0; i < 6; i++) { + await tester.drag(list, const Offset(0, -500)); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + }, variant: _android); + + testWidgets('narrowest large layout at 2x text with long names and counters puts the name under the picture', ( + tester, + ) async { + forums = [for (var i = 0; i < 6; i++) '很长很长的版块名称$i' * 6]; + counts = (threads: '123456789012', replies: '98765432109876'); + await pumpTopics(tester, const Size(topicsPageLargeCardWidth, 700), textScale: 2); + expect(tester.takeException(), isNull); + expect(tester.widgetList(find.byType(ForumCard)).every((e) => e.large), isTrue); + expect(tester.getRect(card(1)).top, moreOrLessEquals(tester.getRect(card(0)).top)); + + // Too narrow for a 2x name beside the picture: the picture keeps its full size and the name goes under it. + final layout = forumCardLargeHeaderLayout(contentWidth(tester, 0), 2); + expect(layout.sideBySide, isFalse); + expect(imageSize(tester, 0), forumCardLargeImageSize); + final nameTop = tester.getRect(find.text(forums.first)).top; + expect(nameTop, greaterThanOrEqualTo(tester.getRect(image(0)).bottom)); + expectStatsFillBottom(tester, 0); + + final list = find.descendant(of: find.byType(TabBarView), matching: find.byType(ListView)).first; + for (var i = 0; i < 6; i++) { + await tester.drag(list, const Offset(0, -400)); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + }, variant: _windows); + + testWidgets('narrow desktop at 1x keeps the picture beside the name, long names and counters fit', (tester) async { + forums = [for (var i = 0; i < 6; i++) '很长很长的版块名称$i' * 6]; + counts = (threads: '123456789012', replies: '98765432109876'); + await pumpTopics(tester, const Size(topicsPageLargeCardWidth, 800)); + expect(tester.takeException(), isNull); + expect(tester.widgetList(find.byType(ForumCard)).every((e) => e.large), isTrue); + + final layout = forumCardLargeHeaderLayout(contentWidth(tester, 0), 1); + expect(layout.sideBySide, isTrue); + expect(layout.image.width, inInclusiveRange(forumCardLargeImageMinWidth, forumCardLargeImageSize.width)); + expect(imageSize(tester, 0), layout.image); + final name = tester.getRect(find.text(forums.first)); + final picture = tester.getRect(image(0)); + expect(name.left, greaterThanOrEqualTo(picture.right + forumCardLargeImageGap - 0.5)); + expect(name.right, lessThanOrEqualTo(tester.getRect(card(0)).right - forumCardLargePadding.right + 0.5)); + expectStatsFillBottom(tester, 0); + + final list = find.descendant(of: find.byType(TabBarView), matching: find.byType(ListView)).first; + for (var i = 0; i < 6; i++) { + await tester.drag(list, const Offset(0, -400)); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + }, variant: _windows); + + testWidgets('large cards and moderator chips still navigate', (tester) async { + final r = await pumpTopics(tester, const Size(1600, 1000)); + await tester.tap(find.text('活动专区')); + await pumpFrames(tester); + expect(find.text('forum 1001'), findsOneWidget); + + r.pop(); + await pumpFrames(tester); + await tester.tap(find.widgetWithText(ActionChip, '捕风巫')); + await pumpFrames(tester); + expect(find.text('profile 捕风巫'), findsOneWidget); + expect(tester.takeException(), isNull); + }, variant: _windows); +} diff --git a/test/regression/test_170_subforum_cards_test.dart b/test/regression/test_170_subforum_cards_test.dart new file mode 100644 index 000000000..6943f32a7 --- /dev/null +++ b/test/regression/test_170_subforum_cards_test.dart @@ -0,0 +1,378 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart'; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/forum/models/models.dart'; +import 'package:tsdm_client/features/forum/repository/forum_repository.dart'; +import 'package:tsdm_client/features/forum/utils/group.dart'; +import 'package:tsdm_client/features/forum/view/forum_group_page.dart'; +import 'package:tsdm_client/features/forum/view/forum_page.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_state_cubit.dart'; +import 'package:tsdm_client/features/notification/repository/notification_info_repository.dart'; +import 'package:tsdm_client/features/notification/repository/notification_repository.dart'; +import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; +import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/card/forum_card.dart'; +import 'package:tsdm_client/widgets/network_indicator_image.dart'; +import 'package:universal_html/html.dart' as uh; +import 'package:universal_html/parsing.dart'; + +/// preview109 feedback: the sub forums tab of a forum and the forum group page (`gid`) still used the shared 960px +/// list width and compact 88x44 cards on desktop windows, while the topics page (test_169) already showed large cards. +/// Every forum list now shares [forumCardListLayout]: large cards in up to [forumCardListMaxWidth] once the page really +/// has [forumCardListLargeWidth] (measured inside the SafeArea) on a desktop platform. Phones must not use the desktop +/// size: Android keeps the compact cards at any width, a 1600 wide landscape included. Desktop cases run as Windows, +/// phone cases as Android. +/// +/// The forum page opens its sub forums tab by itself when the forum has no thread; that switch used to run inside the +/// BlocBuilder and called setState during the build, so every forum page case here also covers it. +/// +/// Fake data only: no forum icon and no network. +const _parentFid = '73'; + +/// Desktop window. +const _windows = TargetPlatformVariant({TargetPlatform.windows}); + +/// Phone. +const _android = TargetPlatformVariant({TargetPlatform.android}); + +/// No network in tests. +class _OfflineAdapter implements HttpClientAdapter { + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async => throw DioException.connectionError(requestOptions: options, reason: 'offline'); + + @override + void close({bool force = false}) {} +} + +String _row(int fid, String name) => + '' + '

$name

' + '123456 / 9876543'; + +/// Forum [_parentFid] with no thread and [names] as sub forums fid 2000, 2001... +String _forumPage(List names) => + '
' + '
' + '${names.indexed.map((e) => _row(2000 + e.$1, e.$2)).join()}' + '
'; + +/// Forum index with one group holding [names] as fid 2000, 2001... +String _groupPage(List names) => + '
' + '${names.indexed.map((e) => _row(2000 + e.$1, e.$2)).join()}' + '
'; + +final class _FakeForumRepository implements ForumRepository { + _FakeForumRepository(this.names); + + final List Function() names; + + @override + AsyncEither fetchForum({required String fid, required FilterState filterState, int pageNumber = 1}) => + AsyncEither(() async => right(parseHtmlDocument(_forumPage(names())))); + + @override + AsyncEither fetchForumGroup(String gid) => + AsyncEither(() async => right(buildGroupListFromDocument(parseHtmlDocument(_groupPage(names()))).firstOrNull)); +} + +enum _Page { subforums, group } + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + late AppDatabase db; + late SettingsRepository settings; + late AuthenticationRepository auth; + var names = []; + + setUp(() async { + names = ['美术部', '音乐部', '文学部', '摄影部']; + db = AppDatabase(NativeDatabase.memory()); + final storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerSingleton(NetErrorSaver()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ); + await settings.init(); + auth = AuthenticationRepository(); + }); + tearDown(() async { + await auth.dispose(); + await getIt.get().dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + /// `pumpAndSettle` never returns while a refresh indicator animates. + Future pumpFrames(WidgetTester tester) async { + for (var i = 0; i < 12; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + } + + Future pumpPage( + WidgetTester tester, + _Page page, + Size size, { + double textScale = 1, + FakeViewPadding padding = FakeViewPadding.zero, + }) async { + tester.view + ..physicalSize = size + ..devicePixelRatio = 1 + ..padding = padding; + addTearDown(tester.view.reset); + if (textScale != 1) { + tester.platformDispatcher.textScaleFactorTestValue = textScale; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + } + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => switch (page) { + _Page.subforums => const ForumPage(fid: _parentFid), + _Page.group => const ForumGroupPage(gid: '1'), + }, + ), + GoRoute( + path: ScreenPaths.forum, + name: ScreenPaths.forum, + builder: (_, state) => Text('forum ${state.pathParameters['fid']}'), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider(create: (_) => _FakeForumRepository(() => names)), + RepositoryProvider.value(value: auth), + RepositoryProvider(create: (_) => FragmentsRepository()), + RepositoryProvider(create: (_) => NotificationRepository()), + RepositoryProvider(create: (_) => NotificationInfoRepository()), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (ctx) => SettingsBloc( + settingsRepository: settings, + fragmentsRepository: RepositoryProvider.of(ctx), + ), + ), + BlocProvider( + create: (ctx) => NotificationStateCubit(RepositoryProvider.of(ctx)), + ), + BlocProvider( + create: (ctx) => NotificationBloc( + notificationRepository: RepositoryProvider.of(ctx), + infoRepository: RepositoryProvider.of(ctx), + authRepo: auth, + storageProvider: getIt.get(), + ), + ), + ], + child: MaterialApp.router(routerConfig: router), + ), + ), + ), + ); + // The forum page switches to its sub forums tab by itself: the forum has no thread. + await pumpFrames(tester); + } + + Finder card(int index) => find.byType(ForumCard).at(index); + Iterable cards(WidgetTester tester) => tester.widgetList(find.byType(ForumCard)); + Size imageSize(WidgetTester tester, int index) => + tester.getSize(find.descendant(of: card(index), matching: find.byType(NetworkIndicatorImage))); + Future scrollWithoutError(WidgetTester tester) async { + // The list holding the cards, kept once found: the first card leaves the tree while scrolling. + final listView = tester.widget(find.ancestor(of: card(0), matching: find.byType(ListView)).first); + final list = find.byWidget(listView); + for (var i = 0; i < 6; i++) { + await tester.drag(list, const Offset(0, -400)); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + } + } + + /// Tab bar controller of the forum page. + TabController forumTabs(WidgetTester tester) => tester.widget(find.byType(TabBar)).controller!; + + for (final page in _Page.values) { + group(page.name, () { + testWidgets('desktop 1600: two columns of large cards across the wide content area', (tester) async { + await pumpPage(tester, page, const Size(1600, 1000)); + expect(tester.takeException(), isNull); + expect(find.byType(ForumCard), findsNWidgets(4)); + expect(cards(tester).every((e) => e.large), isTrue); + + const side = (1600 - forumCardListMaxWidth) / 2; + final first = tester.getRect(card(0)); + final second = tester.getRect(card(1)); + expect(first.left, moreOrLessEquals(side)); + expect(second.right, moreOrLessEquals(1600 - side)); + expect(second.top, moreOrLessEquals(first.top)); + expect(first.width, moreOrLessEquals((forumCardListMaxWidth - appSurfaceGap) / 2)); + expect(tester.getRect(card(2)).top, greaterThan(first.bottom)); + + // Same large content as the topics page: 240x120 picture, 24px name, three counters filling the bottom. + expect(imageSize(tester, 0), forumCardLargeImageSize); + expect(tester.widget(find.text('美术部')).style?.fontSize, 24); + expect(find.descendant(of: card(0), matching: find.byType(ForumCardStat)), findsNWidgets(3)); + }, variant: _windows); + + testWidgets('Android 1600 wide landscape: phone cards and the shared width, never the desktop size', ( + tester, + ) async { + await pumpPage(tester, page, const Size(1600, 1000)); + expect(tester.takeException(), isNull); + expect(find.byType(ForumCard), findsNWidgets(4)); + expect(cards(tester).any((e) => e.large), isFalse); + final first = tester.getRect(card(0)); + final second = tester.getRect(card(1)); + expect(second.right - first.left, moreOrLessEquals(appListMaxWidth)); + expect(second.top, moreOrLessEquals(first.top)); + expect(imageSize(tester, 0), forumCardImageSize); + expect(tester.widget(find.text('美术部')).style?.fontSize, isNot(24)); + expect(find.descendant(of: card(0), matching: find.byType(AppInfoPill)), findsNWidgets(3)); + expect(find.byType(ForumCardStat), findsNothing); + }, variant: _android); + + testWidgets('1100 at 2x text with long names: still large, nothing overflows', (tester) async { + names = [for (var i = 0; i < 6; i++) '很长很长的子版块名称$i' * 6]; + await pumpPage(tester, page, const Size(forumCardListLargeWidth, 700), textScale: 2); + expect(tester.takeException(), isNull); + expect(cards(tester).every((e) => e.large), isTrue); + expect(tester.getRect(card(1)).top, moreOrLessEquals(tester.getRect(card(0)).top)); + await scrollWithoutError(tester); + }, variant: _windows); + + testWidgets('side insets count: 1140 wide minus a 44 inset stays compact', (tester) async { + await pumpPage(tester, page, const Size(1140, 800), padding: const FakeViewPadding(left: 44)); + expect(tester.takeException(), isNull); + expect(cards(tester).any((e) => e.large), isFalse); + expect(tester.getRect(card(0)).left, greaterThanOrEqualTo(44)); + }, variant: _windows); + + testWidgets('Android landscape 844x390 with side insets: compact cards inside the safe area', (tester) async { + names = [for (var i = 0; i < 6; i++) '很长很长的子版块名称$i' * 3]; + await pumpPage( + tester, + page, + const Size(844, 390), + textScale: 2, + padding: const FakeViewPadding(left: 44, right: 44, bottom: 21), + ); + expect(tester.takeException(), isNull); + expect(cards(tester).any((e) => e.large), isFalse); + for (var i = 0; i < find.byType(ForumCard).evaluate().length; i++) { + final rect = tester.getRect(card(i)); + expect(rect.left, greaterThanOrEqualTo(44)); + expect(rect.right, lessThanOrEqualTo(844 - 44)); + } + await scrollWithoutError(tester); + }, variant: _android); + + testWidgets('phone 320 at 2x text: one compact column, no overflow', (tester) async { + names = [for (var i = 0; i < 6; i++) '很长很长的子版块名称$i' * 4]; + await pumpPage(tester, page, const Size(320, 640), textScale: 2); + expect(tester.takeException(), isNull); + expect(cards(tester).any((e) => e.large), isFalse); + expect(tester.getRect(card(0)).width, moreOrLessEquals(320 - 2 * appPagePadding(320))); + expect(imageSize(tester, 0), forumCardImageSize); + await scrollWithoutError(tester); + }, variant: _android); + + testWidgets('phone 390: tapping a compact card opens the sub forum', (tester) async { + await pumpPage(tester, page, const Size(390, 844)); + expect(cards(tester).any((e) => e.large), isFalse); + await tester.tap(find.text('音乐部')); + await pumpFrames(tester); + expect(find.text('forum 2001'), findsOneWidget); + expect(tester.takeException(), isNull); + }, variant: _android); + + testWidgets('desktop: tapping a large card opens the sub forum', (tester) async { + await pumpPage(tester, page, const Size(1600, 1000)); + expect(cards(tester).every((e) => e.large), isTrue); + await tester.tap(find.text('摄影部')); + await pumpFrames(tester); + expect(find.text('forum 2003'), findsOneWidget); + expect(tester.takeException(), isNull); + }, variant: _windows); + }); + } + + group('forum without threads', () { + Finder tab(String title) => find.descendant(of: find.byType(TabBar), matching: find.text(title)); + + testWidgets('opens the sub forums tab once, the user can still switch tabs, cards navigate', (tester) async { + await pumpPage(tester, _Page.subforums, const Size(390, 844)); + // The switch runs after the loaded state, not during a build. + expect(tester.takeException(), isNull); + expect(forumTabs(tester).index, 2); + expect(find.byType(ForumCard), findsNWidgets(4)); + + // Rebuilds (the tab listener calls setState) no longer force the sub forums tab back. + await tester.tap(tab(t.forumPage.threadTab.title)); + await pumpFrames(tester); + expect(tester.takeException(), isNull); + expect(forumTabs(tester).index, 1); + expect(find.text(t.forumPage.threadTab.noThread), findsOneWidget); + + await tester.tap(tab(t.forumPage.subredditTab.title)); + await pumpFrames(tester); + expect(forumTabs(tester).index, 2); + await tester.tap(find.text('文学部')); + await pumpFrames(tester); + expect(find.text('forum 2002'), findsOneWidget); + expect(tester.takeException(), isNull); + }, variant: _android); + + testWidgets('desktop: opens the sub forums tab with large cards', (tester) async { + await pumpPage(tester, _Page.subforums, const Size(1600, 1000)); + expect(tester.takeException(), isNull); + expect(forumTabs(tester).index, 2); + expect(cards(tester).every((e) => e.large), isTrue); + }, variant: _windows); + }); +} diff --git a/test/regression/test_171_feedback110_test.dart b/test/regression/test_171_feedback110_test.dart new file mode 100644 index 000000000..70814f197 --- /dev/null +++ b/test/regression/test_171_feedback110_test.dart @@ -0,0 +1,1676 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart' hide State; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/authentication/utils/logged_user_parser.dart'; +import 'package:tsdm_client/features/bank/cubit/bank_cubit.dart'; +import 'package:tsdm_client/features/bank/models/bank_data.dart'; +import 'package:tsdm_client/features/bank/repository/bank_repository.dart'; +import 'package:tsdm_client/features/bank/view/bank_page.dart'; +import 'package:tsdm_client/features/blocking/cubit/user_block_cubit.dart'; +import 'package:tsdm_client/features/blocking/repository/user_block_repository.dart'; +import 'package:tsdm_client/features/blocking/utils/thread_author_cache.dart'; +import 'package:tsdm_client/features/chat/view/chat_page.dart'; +import 'package:tsdm_client/features/chat/widgets/chat_message_card.dart'; +import 'package:tsdm_client/features/checkin/bloc/checkin_bloc.dart'; +import 'package:tsdm_client/features/checkin/repository/checkin_repository.dart'; +import 'package:tsdm_client/features/favorite/repository/favorite_repository.dart'; +import 'package:tsdm_client/features/home/cubit/home_cubit.dart'; +import 'package:tsdm_client/features/home/widgets/widgets.dart'; +import 'package:tsdm_client/features/homepage/bloc/homepage_bloc.dart'; +import 'package:tsdm_client/features/homepage/view/homepage_page.dart'; +import 'package:tsdm_client/features/homepage/widgets/home_dashboard.dart'; +import 'package:tsdm_client/features/local_notice/tap.dart'; +import 'package:tsdm_client/features/medal_center/cubit/medal_center_cubit.dart'; +import 'package:tsdm_client/features/medal_center/view/medal_center_page.dart'; +import 'package:tsdm_client/features/notification/bloc/auto_notification_cubit.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_state_cubit.dart'; +import 'package:tsdm_client/features/notification/models/models.dart'; +import 'package:tsdm_client/features/notification/repository/notification_info_repository.dart'; +import 'package:tsdm_client/features/notification/repository/notification_repository.dart'; +import 'package:tsdm_client/features/notification/view/notification_detail_page.dart'; +import 'package:tsdm_client/features/notification/view/notification_page.dart'; +import 'package:tsdm_client/features/profile/bloc/current_title_cubit.dart'; +import 'package:tsdm_client/features/profile/models/secondary_title.dart'; +import 'package:tsdm_client/features/profile/repository/profile_repository.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/features/red_packet/utils/parse_red_packet.dart'; +import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; +import 'package:tsdm_client/features/settings/view/settings_page.dart'; +import 'package:tsdm_client/features/theme/cubit/theme_cubit.dart'; +import 'package:tsdm_client/features/thread/v1/utils/parse_thread_document.dart'; +import 'package:tsdm_client/features/thread/v1/view/thread_page.dart'; +import 'package:tsdm_client/features/thread_visit_history/bloc/thread_visit_history_bloc.dart'; +import 'package:tsdm_client/features/thread_visit_history/repository/thread_visit_history_repository.dart'; +import 'package:tsdm_client/features/thread_visit_history/view/thread_visit_history_page.dart'; +import 'package:tsdm_client/features/thread_visit_history/widgets/thread_visit_history_card.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/routes/screen_paths.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; +import 'package:tsdm_client/shared/repositories/forum_home_repository/forum_home_repository.dart'; +import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; +import 'package:tsdm_client/widgets/app_surface.dart'; +import 'package:tsdm_client/widgets/card/post_card/post_card.dart'; +import 'package:tsdm_client/widgets/heroes.dart'; +import 'package:universal_html/html.dart' as uh; +import 'package:universal_html/parsing.dart'; + +/// Android tester feedback on preview 109 (PR #135, `work/pr135-feedback110`), on the real pages: +/// +/// 1. the category picker of the medal center and the service picker of the bank keep their label inside the field; +/// 2. the landscape navigation rail never scrolls under the status bar and still reaches its nine entries; +/// 3. the account filter of the browsing history stays in the safe area, in line with the list; +/// 4. the reply bar paints its background to the screen edges, its field stays in the safe area; +/// 5. the author dialog shows the second badge of the floor, or the current account's own title on its own floors +/// only, never another account's; +/// 6. the homepage app bar does not flash the old activity entry while the page loads or refreshes; +/// 7. the "unread only" filter of the notifications is in the app bar; +/// 8. the daily red packet entry checks the packet alone, the homepage is not reloaded; +/// 9. the warning icon of the debug section lines up with the other icons of the settings. +/// 10. the author dialog keeps the uid and online pills of its header inside a phone in landscape. +/// +/// Synthetic accounts and pages only: no network, no login, nothing is claimed, bought or sent. +Translations get tr => LocaleSettings.instance.currentTranslations; + +String _data(String name) => File('test/data/$name').readAsStringSync(); + +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); + +/// Every image request fails at once: images are not part of these tests. +final class _OfflineAdapter implements HttpClientAdapter { + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) => + throw DioException.connectionError(requestOptions: options, reason: 'offline'); + + @override + void close({bool force = false}) {} +} + +/// A few frames with real time in between, for database and fake network answers. +Future _settle(WidgetTester tester, {int rounds = 8}) async { + for (var i = 0; i < rounds; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 50)); + } +} + +/// A [size] window with [padding] insets (status bar, cutout, gesture bar), a [scale] text scale and a software +/// keyboard [keyboard] high (0: hidden). +/// +/// As on a device, the keyboard covers the gesture bar: the padding left above it is what the view padding exceeds the +/// keyboard by. +void _window( + WidgetTester tester, + Size size, { + FakeViewPadding padding = FakeViewPadding.zero, + double scale = 1, + double keyboard = 0, +}) { + tester.view + ..physicalSize = size + ..devicePixelRatio = 1 + ..viewPadding = padding + ..viewInsets = FakeViewPadding(bottom: keyboard) + ..padding = FakeViewPadding( + left: padding.left, + top: padding.top, + right: padding.right, + bottom: keyboard > 0 ? 0 : padding.bottom, + ); + tester.platformDispatcher.textScaleFactorTestValue = scale; + addTearDown(tester.view.reset); + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); +} + +/// Landscape phone with the cutout on the start side, as in the screenshots. +const _landscape = Size(792, 368); +const _landscapeInsets = FakeViewPadding(left: 44, top: 24); + +/// Portrait phone with a status bar and a gesture bar. +const _portrait = Size(384, 792); +const _portraitInsets = FakeViewPadding(top: 24, bottom: 24); + +// --------------------------------------------------------------------------------------------------------------------- +// 1. Pickers. + +const _bank = ForumBank(id: 1, name: 'Synthetic bank', hasAccount: true); + +final class _BankRepository extends BankRepository { + _BankRepository() : super(getPage: (_) async => '', postForm: (_, _) async => ''); + + @override + Future fetchDirectory(int uid) async => const BankDirectory(banks: [_bank]); + + @override + Future fetchSavings(int bankId, int uid) async => parseBankSavings( + parseHtmlDocument(''' +
  • Test coins:800(银行货币)
+ + +

活期储蓄

您当前的存款金额为 100 ,活期利率为1‰。
+ + + + +
+'''), + bankId: bankId, + ); + + @override + Future fetchLogs(int bankId, int uid, {bool received = false, int page = 1}) async => + BankLogs(entries: [], hasNext: false); +} + +// --------------------------------------------------------------------------------------------------------------------- +// 4. Pages with a reply bar: chat, thread, notice detail. Read only. + +const _tid = '1264975'; +const _pid = 11; + +/// A floor of a thread page as in test_102 (grounded in the Discuz templates). +String _threadFloor({required int pid, required int floor, required int uid, required String name}) => + ''' +
+ + +
+
floor $floor text
'''; + +/// A thread page of board 8 with two floors, as in test_102, open for replies. +/// +/// The reply form is the fast post form of a real X5 page (`test/data/fastpostform_open_x5.html`, as in test_059): +/// without `form#fastpostform` the page reads as a thread that accepts no reply (`isThreadClosedForReply`), the bar +/// is locked and a tap opens no editor. +final _threadPage = + ''' + + +
+

Synthetic thread

+${_threadFloor(pid: 10, floor: 1, uid: 1002, name: 'Carol')} +${_threadFloor(pid: _pid, floor: 2, uid: 1002, name: 'Carol')} +
+
+
+ + + +
+'''; + +/// Read-only answers of the pages with a reply bar: the chat dialog of test_067, the thread page above for the thread +/// and for the notice detail (a `findpost` link to floor [_pid]). Anything else, images included, is offline; every +/// request is recorded so a test can check nothing was sent. +final class _PagesAdapter implements HttpClientAdapter { + final requests = <(String, Uri)>[]; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final uri = options.uri; + requests.add((options.method, uri)); + final q = uri.queryParameters; + if (options.method == 'GET' && q['op'] == 'showmsg') { + return ResponseBody.fromString( + _data('chat_dialog_x5.xml'), + 200, + headers: { + Headers.contentTypeHeader: ['text/xml; charset=utf-8'], + }, + ); + } + final threadPage = q['mod'] == 'viewthread' || (q['mod'] == 'redirect' && q['goto'] == 'findpost'); + if (options.method == 'GET' && uri.pathSegments.lastOrNull == 'forum.php' && threadPage) { + return ResponseBody.fromString( + _threadPage, + 200, + headers: { + Headers.contentTypeHeader: ['text/html; charset=utf-8'], + }, + ); + } + throw DioException.connectionError(requestOptions: options, reason: 'offline'); + } + + @override + void close({bool force = false}) {} +} + +// --------------------------------------------------------------------------------------------------------------------- +// 6 / 7 / 8. Homepage and notification page, as in test_101. + +/// Authentication with a current account the test sets; the homepage checks a fetched page with it. +final class _Auth extends Fake implements AuthenticationRepository { + _Auth(this.currentUser); + + @override + UserLoginInfo? currentUser; + + final _controller = StreamController.broadcast(sync: true); + + @override + Stream get status => _controller.stream; + + @override + int? get effectiveCurrentUid => currentUser?.uid; + + @override + AsyncVoidEither loginWithDocument(uh.Document document) => AsyncVoidEither(() async => rightVoid()); + + Future close() => _controller.close(); +} + +/// Notification bloc whose state the test sets; events are recorded, never handled. +final class _Notifications extends Fake implements NotificationBloc { + _Notifications(this._state); + + NotificationState _state; + final _controller = StreamController.broadcast(sync: true); + final events = []; + + @override + NotificationState get state => _state; + + @override + Stream get stream => _controller.stream; + + void push(NotificationState next) { + _state = next; + _controller.add(next); + } + + @override + void add(NotificationEvent event) => events.add(event); + + @override + bool get isClosed => false; + + @override + Future close() => _controller.close(); +} + +/// `forum.php` of the account [uid], with today's daily red packet when [packet]. +/// +/// The owner marks are those of the real X5 page (`test/data/forum_index_x5.html`): `discuz_uid` in the head script +/// and the user node under `div#hd div.wp div.hdc.cl div#um`. The red packet check only trusts a page whose owner it +/// can read (`parseLoggedUidFromDocument`); a bare `div#um` has none, and the check rightly dropped the answer. +String _forumHome({required bool packet, int uid = 1000, String name = 'Alice'}) => + ''' + + + +
+
+

$name

+
+
+${packet ? '' : ''} +'''; + +const _profile = ''' + + +
+'''; + +/// Answers `forum.php` (with or without the packet, for [homeUid]), the profile page, an empty page otherwise. A +/// pending [hold] delays the `forum.php` answers. +final class _ForumAdapter implements HttpClientAdapter { + final requests = <(String, Uri)>[]; + bool packet = false; + int homeUid = 1000; + Completer? hold; + + Iterable get homeRequests => requests + .where((e) => e.$2.pathSegments.lastOrNull == 'forum.php' && e.$2.queryParameters['mod'] == null) + .map( + (e) => e.$2, + ); + + Iterable get profileRequests => requests + .where((e) => e.$2.pathSegments.lastOrNull == 'home.php' && e.$2.queryParameters['mod'] == 'space') + .map((e) => e.$2); + + Iterable get posts => requests.where((e) => e.$1 == 'POST').map((e) => e.$2); + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final uri = options.uri; + requests.add((options.method, uri)); + if (uri.path.endsWith('.jpg')) { + throw DioException.connectionError(requestOptions: options, reason: 'offline'); + } + final q = uri.queryParameters; + final isHome = uri.pathSegments.lastOrNull == 'forum.php' && q['mod'] == null; + if (isHome && hold != null) { + await hold!.future; + } + final body = switch (uri.pathSegments.lastOrNull) { + 'forum.php' when q['mod'] == null => _forumHome( + packet: packet, + uid: homeUid, + name: homeUid == 1000 ? 'Alice' : 'Someone', + ), + 'home.php' when q['mod'] == 'space' && q['uid'] == '${_alice.uid}' => _profile, + _ => '', + }; + return ResponseBody.fromString( + body, + 200, + headers: { + Headers.contentTypeHeader: ['text/html; charset=utf-8'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +// --------------------------------------------------------------------------------------------------------------------- +// 5. Author dialog: the X5 floor of test #011 (author uid 1115, secondary title in its author column). + +const _floorTitle = 'https://img.tsdm39.com/img01/title/无职转生.gif'; +const _floorAuthor = 1115; + +/// Titles the accounts of the dialog tests use, served by [_ImageAdapter] like every other image. +const _ownTitle = 'https://img.tsdm39.com/img01/title/synthetic-own.gif'; +const _nextTitle = 'https://img.tsdm39.com/img01/title/synthetic-next.gif'; + +/// A 1x1 transparent PNG (as in test_078), the bytes of every image of the dialog tests. +final Uint8List _png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', +); + +/// Local stand-in of the image hosts: answers [_png] for every url and records the urls (decoded, without query), so +/// the badges of the dialog load and render for real and nothing leaves the test. +final class _ImageAdapter implements HttpClientAdapter { + final requested = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + final uri = options.uri; + requested.add(Uri.decodeFull('${uri.scheme}://${uri.host}${uri.path}')); + return ResponseBody.fromBytes( + _png, + 200, + headers: { + Headers.contentTypeHeader: ['image/png'], + }, + ); + } + + @override + void close({bool force = false}) {} +} + +uh.Element _floor(uh.Document doc) => doc.querySelector('div#post_77983102')!; + +/// The floor of the fixture as a post; [strip] removes its secondary title block, [linked] wraps the title image in a +/// link. The body is replaced with plain text, only the author column matters here. +Post _post({bool strip = false, bool linked = false}) { + final doc = parseHtmlDocument(_data('locked_purchase_x5.html')); + final original = _floor(doc); + final floor = original.clone(true) as uh.Element; + if (strip) { + floor.querySelectorAll('div.tsdmtitle-badges').forEach((e) => e.remove()); + } + if (linked) { + final img = floor.querySelector('div.tsdmtitle-title > img')!; + final link = uh.Element.tag('a')..setAttribute('href', 'plugin.php?id=tsdmtitle:tsdmtitle'); + img.replaceWith(link); + link.append(img); + } + original.parent!.append(floor); + final post = Post.fromPostNode(floor, 1)!; + return post.copyWith(data: '

floor body

', locked: const [], signature: null, pokemon: null, checkin: null); +} + +void main() { + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.en); + }); + + group('1. pickers keep their label inside the field', () { + Future pumpBank(WidgetTester tester) async { + final cubit = BankCubit(currentUid: () => 1000, repository: _BankRepository.new); + addTearDown(cubit.close); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp(home: BankPage(controller: cubit)), + ), + ); + await tester.pumpAndSettle(); + } + + Finder bankDecorator() => find + .ancestor(of: find.byKey(const ValueKey('bank-service-picker')), matching: find.byType(InputDecorator)) + .first; + + void expectFilledStyle(WidgetTester tester, Finder decorator) { + final border = tester.widget(decorator).decoration.border; + expect(border, isA(), reason: 'the label floats inside the fill, not on an outline'); + expect(border!.borderSide, BorderSide.none); + } + + for (final (name, size, padding, scale) in [ + ('portrait 384', _portrait, _portraitInsets, 1.0), + ('portrait 384 with 2x text', _portrait, _portraitInsets, 2.0), + ('landscape 792x368 with a 44 cutout', _landscape, _landscapeInsets, 1.0), + ]) { + testWidgets('bank services, $name: one label as placeholder, then floating inside the field', (tester) async { + _window(tester, size, padding: padding, scale: scale); + await pumpBank(tester); + + // Nothing picked: the label is the placeholder, no second copy of the same text as a hint. + final decorator = bankDecorator(); + expectFilledStyle(tester, decorator); + expect(find.descendant(of: decorator, matching: find.text(tr.bank.services)), findsOneWidget); + + // A bank with an account picks "savings": the label floats, inside the field and above the value. + await tester.tap(find.byKey(const ValueKey('bank-1'))); + await tester.pumpAndSettle(); + final box = tester.getRect(bankDecorator()); + final label = tester.getRect(find.descendant(of: bankDecorator(), matching: find.text(tr.bank.services))); + final value = tester.getRect( + find.descendant(of: find.byKey(const ValueKey('bank-service-picker')), matching: find.text(tr.bank.savings)), + ); + expect(label.top, greaterThanOrEqualTo(box.top + 4), reason: 'not on the top edge of the field'); + expect(label.bottom, lessThanOrEqualTo(value.top + 0.5), reason: 'label and value do not overlap'); + expect(value.bottom, lessThanOrEqualTo(box.bottom)); + expect(tester.takeException(), isNull); + }); + } + + for (final scale in [1.0, 2.0]) { + testWidgets('medal center category at ${scale}x text: the label stays inside the field', (tester) async { + _window(tester, _portrait, padding: _portraitInsets, scale: scale); + final cubit = MedalCenterCubit(currentUid: () => null, fetchPage: (_) async => _data('medal_guest_x5.html')); + addTearDown(cubit.close); + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: MedalCenterPage(controller: cubit, imageBuilder: (_) => const Icon(Icons.broken_image_outlined)), + ), + ), + ); + await tester.pumpAndSettle(); + final field = find.byType(DropdownButtonFormField); + expect(field, findsOneWidget); + final decorator = find.descendant(of: field, matching: find.byType(InputDecorator)).first; + expectFilledStyle(tester, decorator); + final box = tester.getRect(decorator); + final label = tester.getRect(find.descendant(of: decorator, matching: find.text(tr.medalCenter.category))); + expect(label.top, greaterThanOrEqualTo(box.top + 4), reason: 'not on the top edge of the field'); + expect(label.bottom, lessThanOrEqualTo(box.bottom)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + }); + } + }); + + group('2. navigation rail in landscape', () { + late GoRouter router; + late HomeCubit cubit; + + Future pumpRail(WidgetTester tester) async { + cubit = HomeCubit(); + router = GoRouter( + initialLocation: ScreenPaths.homepage, + routes: [ + ShellRoute( + builder: (context, state, child) => Scaffold( + body: Row( + children: [ + const HomeNavigationRail(), + Expanded(child: child), + ], + ), + ), + routes: [ + for (final path in [ScreenPaths.homepage, ScreenPaths.topic, '/settings']) + GoRoute( + path: path, + name: path, + pageBuilder: (context, state) => NoTransitionPage(child: Center(child: Text('tab $path'))), + ), + ], + ), + for (final path in [ + ScreenPaths.activities, + ScreenPaths.notice, + ScreenPaths.loggedUserProfile, + ScreenPaths.titleShop, + ScreenPaths.medalCenter, + ScreenPaths.bank, + ScreenPaths.login, + ]) + GoRoute( + path: path, + name: path, + builder: (context, state) => Scaffold(appBar: AppBar(), body: Text('pushed $path')), + ), + ], + ); + addTearDown(router.dispose); + addTearDown(cubit.close); + await tester.pumpWidget( + TranslationProvider( + child: BlocProvider.value( + value: cubit, + child: MaterialApp.router(routerConfig: router), + ), + ), + ); + await tester.pumpAndSettle(); + } + + List labels() => [ + tr.navigation.homepage, + tr.navigation.topics, + tr.navigation.activities, + tr.navigation.notice, + tr.navigation.profile, + tr.navigation.titleShop, + tr.navigation.medalCenter, + tr.navigation.bank, + tr.navigation.settings, + ]; + + for (final scale in [1.0, 2.0]) { + testWidgets('scrolled to the end at ${scale}x text: nothing under the status bar, every entry reachable', ( + tester, + ) async { + _window(tester, _landscape, padding: _landscapeInsets, scale: scale); + await pumpRail(tester); + final scroll = find.byKey(const ValueKey('home-navigation-rail-scroll')); + final viewport = tester.getRect(scroll); + expect(viewport.top, 24, reason: 'the scroll area starts below the status bar'); + expect(viewport.bottom, _landscape.height); + // Whatever scrolls past the top of that area is clipped there, it is never painted in the status bar band. + expect(tester.widget(scroll).clipBehavior, Clip.hardEdge); + + // Drag the rail as far as it goes, the way the tester did. + await tester.drag(scroll, const Offset(0, -2000)); + await tester.pumpAndSettle(); + final settings = tester.getRect(find.text(tr.navigation.settings)); + // The scroll ends with the last entry fully shown at the bottom: no empty room after it, no half clipped entry + // at the top edge (the height is measured, not estimated). + expect(settings.top, greaterThanOrEqualTo(24), reason: 'the last entry is not clipped by the top edge'); + expect(settings.bottom, lessThanOrEqualTo(_landscape.height)); + expect(settings.bottom, greaterThan(viewport.bottom - 48), reason: 'the scroll ends at the last entry'); + // The status bar strip is the rail's own band, not a destination scrolled under it. + final railX = tester.getRect(find.byType(NavigationRail)).center.dx; + await tester.tapAt(Offset(railX, 12)); + await tester.pumpAndSettle(); + expect(routerTopLocation(router), ScreenPaths.homepage, reason: 'a tap on the status bar opens nothing'); + + // Every entry can still be scrolled into the area below the status bar, and stays right of the cutout. + for (final label in labels().reversed) { + await tester.dragUntilVisible(find.text(label), scroll, const Offset(0, 60)); + await tester.pumpAndSettle(); + final rect = tester.getRect(find.text(label)); + expect(rect.top, greaterThanOrEqualTo(23.5), reason: '$label is shown below the status bar'); + expect(rect.left, greaterThanOrEqualTo(44), reason: '$label is right of the cutout'); + } + expect(tester.getRect(find.text(tr.navigation.homepage)).top, greaterThanOrEqualTo(24)); + + // And they still work. + final bank = find.byIcon(Icons.account_balance_outlined); + await tester.dragUntilVisible(bank, scroll, const Offset(0, -60)); + await tester.pumpAndSettle(); + await tester.tap(bank); + await tester.pumpAndSettle(); + expect(routerTopLocation(router), ScreenPaths.bank); + expect(tester.takeException(), isNull); + }); + } + }); + + group('3. browsing history filter in the safe area', () { + late AppDatabase db; + late StorageProvider storage; + + setUp(() { + db = AppDatabase(NativeDatabase.memory()); + storage = StorageProvider(db, {}, {}); + }); + tearDown(() async => db.close()); + + Future pumpHistory(WidgetTester tester) async { + await tester.runAsync(() async { + for (final (uid, tid, name) in [(11, 100, 'Account A'), (22, 101, 'Account B')]) { + await storage.updateThreadVisitHistory( + uid: uid, + tid: tid, + fid: 1, + username: name, + threadTitle: '$name thread', + forumName: 'Forum', + visitTime: DateTime(2026, 9, 10, uid), + ); + } + }); + await tester.pumpWidget( + RepositoryProvider.value( + value: ThreadVisitHistoryRepo(storage), + child: TranslationProvider(child: const MaterialApp(home: ThreadVisitHistoryPage())), + ), + ); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pumpAndSettle(); + } + + for (final (name, size, padding) in [ + ('landscape with a 44 cutout on the left', _landscape, _landscapeInsets), + ('landscape with the cutout on the right', _landscape, const FakeViewPadding(right: 44, top: 24)), + ('portrait', _portrait, _portraitInsets), + ]) { + testWidgets('$name: the account chip is in line with the cards and still filters', (tester) async { + _window(tester, size, padding: padding); + await pumpHistory(tester); + final chip = tester.getRect(find.byType(ActionChip)); + final card = tester.getRect(find.byType(ThreadVisitHistoryCard).first); + expect(chip.left, greaterThanOrEqualTo(padding.left), reason: 'out of the cutout'); + expect(chip.left, closeTo(card.left, 1), reason: 'same start as the list'); + expect(card.right, lessThanOrEqualTo(size.width - padding.right)); + + await tester.tap(find.byType(ActionChip)); + await tester.pumpAndSettle(); + await tester.tap( + find.ancestor( + of: find.descendant(of: find.byType(CheckedPopupMenuItem), matching: find.text('UID 11')), + matching: find.byType(CheckedPopupMenuItem), + ), + ); + await tester.pumpAndSettle(); + final shown = tester.widgetList(find.byType(ThreadVisitHistoryCard)); + expect(shown.map((c) => c.model.uid), [11], reason: 'records of the other account stay out'); + expect(tester.takeException(), isNull); + }); + } + }); + + group('4. reply bar background to the edges, field in the safe area', () { + late AppDatabase db; + late StorageProvider storage; + late SettingsRepository settings; + late SettingsBloc settingsBloc; + late UserBlockRepository blocks; + late _PagesAdapter pages; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + pages = _PagesAdapter(); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerFactory( + () => NetClientProvider.build(dio: Dio(BaseOptions(baseUrl: baseUrl))..httpClientAdapter = pages), + ) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ); + await settings.init(); + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + blocks = UserBlockRepository(storage); + ThreadAuthorCache.clear(); + }); + + tearDown(() async { + await settingsBloc.close(); + await blocks.dispose(); + await getIt.get().dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + /// Pumps [page] under the providers of the app (as in test_102), Alice logged in. + Future pumpPage(WidgetTester tester, Widget page) async { + final auth = AuthenticationRepository(user: _alice); + final info = NotificationInfoRepository(); + final counts = NotificationStateCubit(info); + final history = ThreadVisitHistoryBloc(ThreadVisitHistoryRepo(storage)); + final blockCubit = UserBlockCubit( + repository: blocks, + currentUid: () => auth.currentUser?.uid, + authStatus: auth.status, + retryDelays: const [], + ); + addTearDown(() async { + await blockCubit.close(); + await history.close(); + await counts.close(); + await info.dispose(); + await auth.dispose(); + }); + final router = GoRouter( + routes: [GoRoute(path: '/', builder: (_, _) => page)], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: auth), + RepositoryProvider(create: (_) => FavoriteRepository()), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: blockCubit), + BlocProvider.value( + value: _Notifications(const NotificationState(status: NotificationStatus.success)), + ), + BlocProvider.value(value: counts), + BlocProvider.value(value: history), + ], + child: MaterialApp.router(routerConfig: router), + ), + ), + ), + ); + await _settle(tester, rounds: 12); + } + + Finder background() => find.byKey(const ValueKey('reply-bar-background')); + Finder field() => find.byKey(const ValueKey('reply-bar-field')); + + /// The buttons of the editor sheet, by name: collapse and send. + /// + /// No `.first`: a missing icon fails `findsOneWidget` with its name instead of a bare `Bad state: No element`. + Map editorButtons() => { + for (final (name, icon) in [('collapse', Icons.unfold_less), ('send', Icons.send)]) + name: find.ancestor(of: find.byIcon(icon), matching: find.byWidgetPredicate((w) => w is ButtonStyleButton)), + }; + + test('the thread fixture is a thread open for replies', () { + expect(isThreadClosedForReply(parseHtmlDocument(_threadPage)), isFalse); + }); + + // The three hosts of the bar, with what they show above it. Chat and thread keep their height when the keyboard + // shows (`resizeToAvoidBottomInset: false`, the editor panel handles it); the notice detail resizes. + final hosts = <(String, Widget Function(), Finder Function(), bool)>[ + ('chat', () => const ChatPage(username: 'Bob', uid: '1000'), () => find.byType(ChatMessageCard), false), + ( + 'thread', + () => ThreadPage( + threadID: _tid, + findPostID: null, + pageNumber: '1', + overrideReverseOrder: false, + overrideWithExactOrder: null, + ), + () => find.byType(PostCard), + false, + ), + ( + 'notice detail', + () => NoticeDetailPage( + url: '$baseUrl/forum.php?mod=redirect&goto=findpost&pid=$_pid&ptid=$_tid', + noticeType: NoticeType.reply, + ), + () => find.byType(PostCard), + true, + ), + ]; + + testWidgets('thread: a pull to refresh leaves no blank of the refresh indicator above the title', (tester) async { + _window(tester, _portrait, padding: _portraitInsets); + final (_, page, _, _) = hosts[1]; + await pumpPage(tester, page()); + expect(find.byType(PostCard), findsWidgets); + final appBarBottom = tester.getRect(find.byType(AppBar)).bottom; + final gapBefore = tester.getRect(find.byType(PostCard).first).top - appBarBottom; + final reads = pages.requests.length; + + await tester.drag(find.byType(PostCard).first, const Offset(0, 320)); + await _settle(tester, rounds: 30); + + expect(pages.requests.length, greaterThan(reads), reason: 'the thread was reloaded'); + expect(find.byType(RefreshProgressIndicator), findsNothing, reason: 'the refresh indicator is finished'); + final gapAfter = tester.getRect(find.byType(PostCard).first).top - appBarBottom; + expect(gapAfter, closeTo(gapBefore, 4), reason: 'no blank of the refresh indicator stays above the title'); + expect(tester.takeException(), isNull); + }); + + for (final (host, page, content, _) in hosts) { + for (final (name, size, padding) in [ + ('landscape, cutout on the left', _landscape, _landscapeInsets), + ('landscape, cutout on the right', _landscape, const FakeViewPadding(right: 44, top: 24)), + ('portrait with a gesture bar', _portrait, _portraitInsets), + ]) { + testWidgets('$host, $name: background edge to edge, field, content and editor in the safe area', ( + tester, + ) async { + _window(tester, size, padding: padding); + await pumpPage(tester, page()); + expect(background(), findsOneWidget, reason: 'the real $host page shows its reply bar'); + + final bar = tester.getRect(background()); + expect((bar.left, bar.right), (0, size.width), reason: 'no blank strip beside the bar'); + expect(bar.bottom, size.height, reason: 'down to the bottom edge'); + final box = tester.getRect(field()); + expect(box.left, greaterThanOrEqualTo(padding.left + 12), reason: 'the field is out of the start inset'); + expect(box.right, lessThanOrEqualTo(size.width - padding.right - 12), reason: 'and out of the end inset'); + expect(box.bottom, lessThanOrEqualTo(size.height - (padding.bottom > 4 ? padding.bottom : 4))); + + // The rest of the page keeps its safe area. + expect(content(), findsWidgets); + final shown = tester.getRect(content().first); + expect(shown.left, greaterThanOrEqualTo(padding.left)); + expect(shown.right, lessThanOrEqualTo(size.width - padding.right)); + + // Still the real bar: a tap opens the editor, whose buttons stay out of the insets as well. + expect( + tester.widget(field()).enabled, + isTrue, + reason: 'the $host bar is open for replies (not locked as closed or logged out)', + ); + await tester.tap(field()); + await _settle(tester); + for (final MapEntry(key: name, value: button) in editorButtons().entries) { + expect(button, findsOneWidget, reason: 'the $host editor shows its $name button'); + final rect = tester.getRect(button); + expect(rect.left, greaterThanOrEqualTo(padding.left), reason: 'editor button out of the start inset'); + expect(rect.right, lessThanOrEqualTo(size.width - padding.right), reason: 'and out of the end inset'); + } + expect(pages.requests.where((e) => e.$1 != 'GET'), isEmpty, reason: 'nothing is sent'); + expect(tester.takeException(), isNull); + }); + } + } + + for (final (host, page, _, resizes) in hosts) { + for (final (name, size, padding, keyboard) in [ + ('portrait', _portrait, _portraitInsets, 300.0), + ('landscape, cutout on the left', _landscape, _landscapeInsets, 160.0), + ]) { + testWidgets('$host, $name, keyboard up: no gesture bar padding above the keyboard, field still safe', ( + tester, + ) async { + _window(tester, size, padding: padding, keyboard: keyboard); + await pumpPage(tester, page()); + final bar = tester.getRect(background()); + expect((bar.left, bar.right), (0, size.width)); + // The notice detail stands the bar on the keyboard; chat and thread leave the room to the editor panel. + expect(bar.bottom, resizes ? size.height - keyboard : size.height); + final box = tester.getRect(field()); + // The keyboard covers the gesture bar: under the field only the bar's own 8 and the minimum 4 are left. + expect(bar.bottom - box.bottom, closeTo(12, 0.5)); + expect(box.left, greaterThanOrEqualTo(padding.left + 12)); + expect(box.right, lessThanOrEqualTo(size.width - padding.right - 12)); + expect(pages.requests.where((e) => e.$1 != 'GET'), isEmpty); + expect(tester.takeException(), isNull); + }); + } + } + }); + + group('5. author dialog second badge', () { + late Directory tmp; + late AppDatabase db; + late SettingsRepository settings; + late SettingsBloc settingsBloc; + late _ImageAdapter images; + + // The badges are real images: they go through the image cache of the app, which keeps its files on disk. The + // cache directories live in a temporary folder, the image hosts are [_ImageAdapter]. + setUpAll(() async { + tmp = Directory.systemTemp.createTempSync('tsdm_feedback110_'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => switch (call.method) { + 'getApplicationCachePath' || 'getApplicationCacheDirectory' => '${tmp.path}/cache', + 'getApplicationSupportPath' || 'getApplicationSupportDirectory' => '${tmp.path}/support', + 'getTemporaryPath' || 'getTemporaryDirectory' => '${tmp.path}/tmp', + 'getApplicationDocumentsPath' || 'getApplicationDocumentsDirectory' => '${tmp.path}/docs', + _ => null, + }, + ); + await initCache(); + }); + + tearDownAll(() => tmp.delete(recursive: true)); + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + final storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + images = _ImageAdapter(); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = images)), + ); + await settings.init(); + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + }); + + tearDown(() async { + await settingsBloc.close(); + await getIt.get().dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + }); + + /// Pumps the floor [post] with [auth] as the current account and [titles] as the app's title holder, then opens + /// the author dialog from the avatar of the author row, as in the thread. + Future pumpAndOpen( + WidgetTester tester, + Post post, { + required AuthenticationRepository auth, + required CurrentTitleCubit titles, + }) async { + await tester.pumpWidget( + RepositoryProvider.value( + value: auth, + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: titles), + ], + child: TranslationProvider( + child: MaterialApp( + home: Scaffold(body: SingleChildScrollView(child: PostCard(post))), + ), + ), + ), + ), + ); + await _settle(tester); + // The avatar of the author row opens the dialog, as in the thread. + await tester.tap(find.byType(HeroUserAvatar).first); + await _settle(tester, rounds: 12); + expect(find.text(tr.postCard.profileDialog.badges), findsOneWidget, reason: 'the dialog is open'); + } + + /// The floor [post] read by the account [uid], whose own title (from its titles page) is [accountTitle]. + Future> openDialog(WidgetTester tester, Post post, {required int uid, String? accountTitle}) async { + final reads = []; + final auth = AuthenticationRepository( + user: UserLoginInfo(username: 'Reader', uid: uid), + ); + final status = StreamController.broadcast(sync: true); + final titles = CurrentTitleCubit( + currentUid: () => uid, + authStatus: status.stream, + fetchTitles: () { + reads.add(uid); + return TaskEither.right([ + if (accountTitle != null) SecondaryTitle(id: 1, name: 'Own', imageUrl: accountTitle, activated: true), + ]); + }, + ); + status.add(AuthStatusAuthed(UserLoginInfo(username: 'Reader', uid: uid))); + addTearDown(() async { + await titles.close(); + await status.close(); + await auth.dispose(); + }); + await pumpAndOpen(tester, post, auth: auth, titles: titles); + return reads; + } + + Finder floorTitle() => find.byKey(const ValueKey('brief-profile-floor-title')); + Finder accountTitle() => find.byKey(const ValueKey('brief-profile-account-title')); + Finder groupBadge() => find.byKey(const ValueKey('brief-profile-group-badge')); + + /// [badge] of the dialog shows the image of [url], decoded from the bytes of the local image host. + Future expectPainted(WidgetTester tester, Finder badge, String url) async { + final raw = find.descendant(of: badge, matching: find.byType(RawImage)); + bool painted() => raw.evaluate().isNotEmpty && tester.widget(raw.first).image != null; + // Loading an image is real async work (cache, file, decoding). + for (var i = 0; i < 40 && !painted(); i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 50)); + } + expect(images.requested, contains(url), reason: 'asked from the local image host'); + expect(painted(), isTrue, reason: '$url is decoded and painted in the dialog'); + } + + for (final size in [_landscape, const Size(640, 360)]) { + testWidgets('landscape ${size.width}x${size.height}: the uid and online pills stay in the header', ( + tester, + ) async { + _window(tester, size, padding: _landscapeInsets); + await openDialog(tester, _post(), uid: 1000, accountTitle: _ownTitle); + final pills = find.byType(AppInfoPill); + expect(pills, findsNWidgets(2), reason: '${size.width}x${size.height}: uid and online state'); + final header = find.ancestor(of: pills.first, matching: find.byType(SingleChildScrollView)).first; + final headerRect = tester.getRect(header); + for (var i = 0; i < 2; i++) { + final pill = tester.getRect(pills.at(i)); + expect( + pill.bottom, + lessThanOrEqualTo(headerRect.bottom), + reason: '${size.width}x${size.height}: pill $i is not cut by the title viewport', + ); + expect(pill.top, greaterThanOrEqualTo(headerRect.top)); + } + }); + } + + test('the title image is found in its block also when a link wraps it', () { + expect(_post().secondBadge, _floorTitle); + expect(_post(linked: true).secondBadge, _floorTitle); + expect(_post(strip: true).secondBadge, isNull, reason: 'only the level badge: no second badge is made up'); + expect(_post(strip: true).badge, isNotNull); + expect(_post().userBriefProfile?.uid, '$_floorAuthor'); + }); + + testWidgets('a floor with a title shows it next to the level badge, for another reader', (tester) async { + final reads = await openDialog(tester, _post(), uid: 1000, accountTitle: _ownTitle); + expect(groupBadge(), findsOneWidget); + expect(floorTitle(), findsOneWidget); + expect(tester.widget(floorTitle()).imageUrl, _floorTitle); + await expectPainted(tester, floorTitle(), _floorTitle); + await expectPainted(tester, groupBadge(), _post().badge!); + expect(accountTitle(), findsNothing); + expect(reads, isEmpty, reason: 'the reader title is not even read'); + expect(images.requested, isNot(contains(_ownTitle))); + expect(tester.takeException(), isNull); + }); + + testWidgets('an own floor with its title: the floor wins over the account title', (tester) async { + final reads = await openDialog(tester, _post(), uid: _floorAuthor, accountTitle: _ownTitle); + expect(tester.widget(floorTitle()).imageUrl, _floorTitle); + await expectPainted(tester, floorTitle(), _floorTitle); + await expectPainted(tester, groupBadge(), _post().badge!); + expect(accountTitle(), findsNothing); + expect(reads, isEmpty); + expect(images.requested, isNot(contains(_ownTitle))); + expect(tester.takeException(), isNull); + }); + + testWidgets('another author without a title: only the level badge, never the reader title', (tester) async { + final reads = await openDialog(tester, _post(strip: true), uid: 1000, accountTitle: _ownTitle); + expect(groupBadge(), findsOneWidget); + await expectPainted(tester, groupBadge(), _post().badge!); + expect(floorTitle(), findsNothing); + expect(accountTitle(), findsNothing); + expect(find.byType(SecondaryTitleBadge), findsNothing, reason: 'the reader title stays out of this dialog'); + expect(reads, isEmpty); + expect(images.requested, isNot(contains(_ownTitle))); + expect(tester.takeException(), isNull); + }); + + testWidgets('an own floor without a title in its markup: the title this account uses', (tester) async { + final reads = await openDialog(tester, _post(strip: true), uid: _floorAuthor, accountTitle: _ownTitle); + expect(groupBadge(), findsOneWidget); + expect(floorTitle(), findsNothing); + expect(accountTitle(), findsOneWidget); + final badge = find.descendant(of: accountTitle(), matching: find.byType(SecondaryTitleBadge)); + expect(tester.widget(badge).imageUrl, _ownTitle); + await expectPainted(tester, accountTitle(), _ownTitle); + await expectPainted(tester, groupBadge(), _post().badge!); + expect(reads, [_floorAuthor], reason: 'read once, for this account'); + expect(tester.takeException(), isNull); + }); + + testWidgets('an own floor without a title, and the account uses none: nothing is made up', (tester) async { + await openDialog(tester, _post(strip: true), uid: _floorAuthor); + expect(groupBadge(), findsOneWidget); + await expectPainted(tester, groupBadge(), _post().badge!); + expect(find.byType(SecondaryTitleBadge), findsNothing); + expect(tester.takeException(), isNull); + }); + + // The dialog stays open while the account changes (e.g. switched from another window of the app): the badge room + // of an own floor is tied to the uid of its author, never to "whoever is the reader now". + for (final viaLoading in [true, false]) { + testWidgets( + 'account switch while the dialog is open${viaLoading ? '' : ', no loading status in between'}: neither the late ' + "title of the previous account nor the next account's title is shown for the author", + (tester) async { + var uid = _floorAuthor; + final reads = []; + final answers = >>>{}; + const author = UserLoginInfo(username: 'Author', uid: _floorAuthor); + const next = UserLoginInfo(username: 'Next', uid: 1000); + final auth = AuthenticationRepository(user: author); + final status = StreamController.broadcast(sync: true); + final titles = CurrentTitleCubit( + currentUid: () => uid, + authStatus: status.stream, + fetchTitles: () { + final reader = uid; + reads.add(reader); + final answer = Completer>>(); + answers[reader] = answer; + return TaskEither(() => answer.future); + }, + ); + status.add(const AuthStatusAuthed(author)); + addTearDown(() async { + await titles.close(); + await status.close(); + await auth.dispose(); + }); + + await pumpAndOpen(tester, _post(strip: true), auth: auth, titles: titles); + // Own floor without a title: the badge room waits for the title of this account. + expect(find.descendant(of: accountTitle(), matching: find.byType(SecondaryTitlePlaceholder)), findsOneWidget); + expect(reads, [_floorAuthor]); + + // The account is switched while the dialog is open. + uid = next.uid!; + if (viaLoading) { + status.add(const AuthStatusLoading()); + } + status.add(const AuthStatusAuthed(next)); + await _settle(tester); + + // The next account reads its own title (the homepage does), so the app knows it... + unawaited(titles.ensureLoaded()); + answers[next.uid!]!.complete( + right([const SecondaryTitle(id: 2, name: 'Next', imageUrl: _nextTitle, activated: true)]), + ); + // ...and only then the answer for the previous account arrives, late. + answers[_floorAuthor]!.complete( + right([const SecondaryTitle(id: 1, name: 'Own', imageUrl: _ownTitle, activated: true)]), + ); + await _settle(tester); + + expect(titles.state.imageUrlFor(next.uid), _nextTitle, reason: 'the title of the next account is known'); + expect(titles.state.imageUrlFor(_floorAuthor), isNull, reason: 'the late answer was dropped'); + expect(find.text(tr.postCard.profileDialog.badges), findsOneWidget, reason: 'the dialog is still open'); + await expectPainted(tester, groupBadge(), _post().badge!); + expect(find.byType(SecondaryTitleBadge), findsNothing, reason: 'no title for the author of this floor'); + expect( + find.descendant(of: accountTitle(), matching: find.byType(SecondaryTitlePlaceholder)), + findsNothing, + reason: 'nothing is pending for the author any more', + ); + expect(images.requested, isNot(contains(_nextTitle))); + expect(images.requested, isNot(contains(_ownTitle))); + expect(reads, [_floorAuthor, next.uid]); + expect(tester.takeException(), isNull); + }, + ); + } + }); + + group('6 / 8. homepage', () { + late AppDatabase db; + late StorageProvider storage; + late SettingsRepository settings; + late SettingsBloc settingsBloc; + late UserBlockRepository blocks; + late _Auth auth; + late NotificationInfoRepository info; + late NotificationStateCubit counts; + late _ForumAdapter adapter; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + adapter = _ForumAdapter(); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerFactory( + () => NetClientProvider.build(dio: Dio(BaseOptions(baseUrl: baseUrl))..httpClientAdapter = adapter), + ) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ); + await settings.init(); + await settings.setValue(SettingsKeys.loginUsername, _alice.username!); + await settings.setValue(SettingsKeys.loginUid, _alice.uid!); + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + blocks = UserBlockRepository(storage); + auth = _Auth(_alice); + info = NotificationInfoRepository(); + counts = NotificationStateCubit(info); + }); + + tearDown(() async { + await info.dispose(); + await counts.close(); + await settingsBloc.close(); + await blocks.dispose(); + await auth.close(); + await getIt.get().dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + Future openHomepage(WidgetTester tester) async { + _window(tester, _portrait, padding: _portraitInsets); + final home = HomeCubit(); + final checkin = CheckinBloc( + checkinRepository: CheckinRepository(storageProvider: storage), + authenticationRepository: auth, + settingsRepository: settings, + ); + final forumHome = ForumHomeRepository(); + final blockCubit = UserBlockCubit( + repository: blocks, + currentUid: () => auth.currentUser?.uid, + authStatus: auth.status, + retryDelays: const [], + ); + addTearDown(() async { + await blockCubit.close(); + await home.close(); + await checkin.close(); + await forumHome.dispose(); + }); + final router = GoRouter( + routes: [GoRoute(path: '/', builder: (_, _) => const HomepagePage())], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: auth), + RepositoryProvider.value(value: info), + RepositoryProvider.value(value: forumHome), + RepositoryProvider(create: (_) => ProfileRepository()), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: blockCubit), + BlocProvider.value( + value: _Notifications(const NotificationState(status: NotificationStatus.success)), + ), + BlocProvider.value(value: counts), + BlocProvider.value(value: home), + BlocProvider.value(value: checkin), + ], + child: MaterialApp.router(routerConfig: router), + ), + ), + ), + ); + await _settle(tester); + } + + HomepageBloc bloc(WidgetTester tester) => BlocProvider.of(tester.element(find.byType(AppBar))); + + Finder inAppBar(Finder finder) => find.descendant(of: find.byType(AppBar), matching: finder); + + Finder redPacketButton() => find.ancestor( + of: find.text(tr.redPacket.daily.unavailable), + matching: find.byWidgetPredicate((w) => w is OutlinedButton), + ); + + testWidgets('6. loading and refreshing never flash the activity entry in the app bar', (tester) async { + adapter.hold = Completer(); + await openHomepage(tester); + expect(bloc(tester).state.status, HomepageStatus.loading); + expect(inAppBar(find.byIcon(Icons.event_outlined)), findsNothing, reason: 'first load'); + expect(inAppBar(find.byIcon(Icons.search_outlined)), findsOneWidget, reason: 'search stays'); + + adapter.hold!.complete(); + adapter.hold = null; + await _settle(tester); + expect(bloc(tester).state.status, HomepageStatus.success); + expect(inAppBar(find.byIcon(Icons.event_outlined)), findsNothing); + expect(find.byIcon(Icons.event_outlined), findsWidgets, reason: 'the entry of the greeting card'); + + // A manual refresh reloads everything, and the app bar stays as it was. + adapter.hold = Completer(); + bloc(tester).add(HomepageRefreshRequested()); + await _settle(tester); + expect(bloc(tester).state.status, HomepageStatus.loading); + expect(inAppBar(find.byIcon(Icons.event_outlined)), findsNothing, reason: 'refresh'); + expect(inAppBar(find.byIcon(Icons.search_outlined)), findsOneWidget); + adapter.hold!.complete(); + adapter.hold = null; + await _settle(tester); + expect(bloc(tester).state.status, HomepageStatus.success); + expect(tester.takeException(), isNull); + }); + + test('8. the fake forum pages carry the owner and the packet the way the real parsers read them', () { + final withPacket = parseHtmlDocument(_forumHome(packet: true)); + expect(parseLoggedUidFromDocument(withPacket), 1000); + expect(parseDailyRedPacketConfig(withPacket)?.dateFlag, '20260928'); + expect(parseFormHash(withPacket), 'XXXXXXXX'); + final without = parseHtmlDocument(_forumHome(packet: false)); + expect(parseLoggedUidFromDocument(without), 1000); + expect(parseDailyRedPacketConfig(without), isNull, reason: 'no packet today: the forum embeds no config'); + expect(parseLoggedUidFromDocument(parseHtmlDocument(_forumHome(packet: true, uid: 2000))), 2000); + }); + + testWidgets('8. the red packet entry checks the packet alone: no loading, no reload of the page', (tester) async { + await openHomepage(tester); + expect(bloc(tester).state.status, HomepageStatus.success); + expect(redPacketButton(), findsOneWidget); + final homeBefore = adapter.homeRequests.length; + final profileBefore = adapter.profileRequests.length; + final greeting = tester.element(find.byType(HomeGreetingCard)); + final states = []; + final sub = bloc(tester).stream.listen(states.add); + addTearDown(sub.cancel); + + adapter.packet = true; + await tester.tap(find.text(tr.redPacket.daily.unavailable)); + await _settle(tester); + + expect(adapter.homeRequests.length, homeBefore + 1, reason: 'one read of the forum homepage'); + expect(adapter.profileRequests.length, profileBefore, reason: 'the profile is not read again'); + expect(states.map((s) => s.status), everyElement(HomepageStatus.success), reason: 'never loading'); + expect(states.first.checkingDailyRedPacket, isTrue); + expect(bloc(tester).state.checkingDailyRedPacket, isFalse); + expect(bloc(tester).state.dailyRedPacket?.dateFlag, '20260928', reason: "today's packet of this account"); + expect(identical(tester.element(find.byType(HomeGreetingCard)), greeting), isTrue, reason: 'not rebuilt anew'); + expect(find.text(tr.redPacket.daily.tooltip), findsOneWidget, reason: 'the packet is claimable now'); + expect(adapter.posts, isEmpty, reason: 'checking claims nothing'); + expect(tester.takeException(), isNull); + }); + + testWidgets('8. a page of another account is not taken for the current one', (tester) async { + await openHomepage(tester); + adapter + ..packet = true + ..homeUid = 2000; + await tester.tap(find.text(tr.redPacket.daily.unavailable)); + await _settle(tester); + expect(bloc(tester).state.dailyRedPacket, isNull); + expect(bloc(tester).state.status, HomepageStatus.success); + expect(find.text(tr.redPacket.daily.unavailable), findsOneWidget); + expect(adapter.posts, isEmpty); + }); + + testWidgets('8. an account switch during the check drops its answer', (tester) async { + await openHomepage(tester); + adapter + ..packet = true + ..hold = Completer(); + await tester.tap(find.text(tr.redPacket.daily.unavailable)); + await _settle(tester); + expect(bloc(tester).state.checkingDailyRedPacket, isTrue); + final button = tester.widget(redPacketButton()); + expect(button.onPressed, isNull, reason: 'one check at a time'); + + auth.currentUser = const UserLoginInfo(username: 'Bob', uid: 1001); + adapter.hold!.complete(); + adapter.hold = null; + await _settle(tester); + expect(bloc(tester).state.dailyRedPacket, isNull, reason: 'the answer was for the previous account'); + expect(bloc(tester).state.checkingDailyRedPacket, isFalse); + }); + }); + + group('7. notification page', () { + late AppDatabase db; + late StorageProvider storage; + late SettingsRepository settings; + late SettingsBloc settingsBloc; + late UserBlockRepository blocks; + late _Auth auth; + late NotificationInfoRepository info; + late NotificationStateCubit counts; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ); + await settings.init(); + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + blocks = UserBlockRepository(storage); + auth = _Auth(_alice); + info = NotificationInfoRepository(); + counts = NotificationStateCubit(info); + }); + + tearDown(() async { + await info.dispose(); + await counts.close(); + await settingsBloc.close(); + await blocks.dispose(); + await auth.close(); + await getIt.get().dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + final synced = NotificationState( + status: NotificationStatus.success, + noticeList: const [ + NoticeV2(id: 1, timestamp: 1788000001, data: 'unread notice one'), + NoticeV2(id: 2, timestamp: 1788000002, data: 'read notice two', alreadyRead: true), + ], + ); + + Future open(WidgetTester tester, {required Size size, double scale = 1}) async { + _window(tester, size, scale: scale); + final notifications = _Notifications(const NotificationState(status: NotificationStatus.loading)); + final blockCubit = UserBlockCubit( + repository: blocks, + currentUid: () => auth.currentUser?.uid, + authStatus: auth.status, + retryDelays: const [], + ); + final auto = AutoNotificationCubit( + authenticationRepository: auth, + notificationRepository: NotificationRepository(storageProvider: storage), + storageProvider: storage, + ); + addTearDown(() async { + await blockCubit.close(); + await auto.close(); + }); + final router = GoRouter( + routes: [GoRoute(path: '/', builder: (_, _) => const NotificationPage())], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: auth), + RepositoryProvider.value(value: info), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: blockCubit), + BlocProvider.value(value: notifications), + BlocProvider.value(value: counts), + BlocProvider.value(value: auto), + ], + child: MaterialApp.router(routerConfig: router), + ), + ), + ), + ); + await _settle(tester); + notifications.push(synced); + await _settle(tester); + } + + Finder filter() => find.byKey(const ValueKey('notice-unread-filter')); + + for (final (name, size, scale) in [('384', _portrait, 1.0), ('320 with 2x text', const Size(320, 640), 2.0)]) { + testWidgets('$name: the unread filter is in the app bar, no row of its own, and filters every tab', ( + tester, + ) async { + await open(tester, size: size, scale: scale); + expect(find.descendant(of: find.byType(AppBar), matching: filter()), findsOneWidget); + expect(find.byType(FilterChip), findsNothing, reason: 'the empty row above the tabs is gone'); + final button = tester.widget(filter()); + expect(button.tooltip, contains(tr.noticePage.appBar.unread)); + expect(button.isSelected, isFalse); + expect(find.textContaining('read notice two', findRichText: true), findsOneWidget); + + await tester.tap(filter()); + await _settle(tester); + expect(tester.widget(filter()).isSelected, isTrue); + expect(find.textContaining('unread notice one', findRichText: true), findsOneWidget); + expect(find.textContaining('read notice two', findRichText: true), findsNothing); + + // The choice holds on the other tabs. + await tester.tap(find.text(tr.noticePage.privateMessageTab.title)); + await _settle(tester); + expect(tester.widget(filter()).isSelected, isTrue); + + await tester.tap(filter()); + await _settle(tester); + expect(tester.widget(filter()).isSelected, isFalse); + expect(tester.getRect(filter()).right, lessThanOrEqualTo(size.width)); + expect(tester.takeException(), isNull); + }); + } + }); + + group('9. settings: debug section icon in line', () { + late AppDatabase db; + late SettingsRepository settings; + late SettingsBloc settingsBloc; + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + final storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + getIt + ..registerSingleton(db) + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ); + await settings.init(); + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + }); + + tearDown(() async { + await settingsBloc.close(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + for (final scale in [1.0, 2.0]) { + testWidgets('${scale}x text: the warning icon starts where every other row icon starts', (tester) async { + _window(tester, _portrait, padding: _portraitInsets, scale: scale); + final theme = ThemeCubit(); + addTearDown(theme.close); + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: theme), + ], + child: TranslationProvider(child: const MaterialApp(home: SettingsPage())), + ), + ); + await _settle(tester); + final list = find.descendant(of: find.byType(ListView), matching: find.byType(Scrollable)).first; + final viewport = tester.getRect(list); + final about = find.text(tr.settingsPage.othersSection.about); + await tester.scrollUntilVisible(about, 200, scrollable: list); + await tester.pumpAndSettle(); + expect(about.hitTestable(), findsOneWidget, reason: 'the "about" row is on screen'); + final aboutIcon = tester.getRect( + find.descendant( + of: find.ancestor(of: about, matching: find.byWidgetPredicate((w) => w is ListTile)).first, + matching: find.byIcon(Icons.info_outline), + ), + ); + + // The debug row is measured and tapped where the user sees it. At 2x text the row sits above the viewport once + // "about" is in view, so it is scrolled back into view first: a tap offscreen would hit nothing. + final expansion = find.byKey(const ValueKey('settings-debug-expansion')); + final tip = find.descendant(of: expansion, matching: find.text(tr.settingsPage.debugSection.tip)); + await tester.ensureVisible(tip); + await tester.pumpAndSettle(); + final warningIcon = find.descendant(of: expansion, matching: find.byIcon(Icons.warning_amber_outlined)).first; + expect(warningIcon.hitTestable(), findsOneWidget, reason: 'the warning icon is on screen'); + final warning = tester.getRect(warningIcon); + expect(warning.top, greaterThanOrEqualTo(viewport.top)); + expect(warning.bottom, lessThanOrEqualTo(viewport.bottom)); + expect(warning.left, closeTo(aboutIcon.left, 0.5), reason: 'same leading start as the other rows'); + + // Still an expansion: a tap on the visible row opens it, its rows appear and line up too. + expect(tip.hitTestable(), findsOneWidget); + expect(find.byIcon(Icons.developer_board_outlined), findsNothing, reason: 'collapsed by default'); + await tester.tap(tip); + await tester.pumpAndSettle(); + final debugIcon = find.byIcon(Icons.developer_board_outlined); + await tester.ensureVisible(debugIcon); + await tester.pumpAndSettle(); + expect(debugIcon.hitTestable(), findsOneWidget, reason: 'the expanded row is on screen'); + final debugRow = tester.getRect(debugIcon); + expect(debugRow.left, closeTo(warning.left, 0.5)); + expect(tester.takeException(), isNull); + }); + } + }); +} diff --git a/test/regression/test_172_feedback111_test.dart b/test/regression/test_172_feedback111_test.dart new file mode 100644 index 000000000..11fcb13b0 --- /dev/null +++ b/test/regression/test_172_feedback111_test.dart @@ -0,0 +1,577 @@ +/// Feedback on preview 110 (PR #135) and issue #139. +/// +/// 1. In landscape the expanded reply editor was narrower than the page (the Material 3 default of 640dp), so the +/// collapsed bar showed beside it as a second reply box; the sheet now spans the page. The grip above the editor +/// is gone: the collapse button is the one control closing it (#139). +/// 2. The homepage greeting shows "checked in" once this app recorded a check-in of the account today, and "claimed" +/// once it recorded a claim of the daily red packet today, instead of "check in" / "none" until the next attempt. +/// 3. The two badges of a floor author (user group, secondary title) are the same height, in the floor and in the +/// author dialog. +/// 4. Second round: a landscape phone is not a wide window, the homepage keeps its phone layout on Android and iOS at +/// any width; the theme mode switch of the settings page sits in one line with its title. +library; + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fpdart/fpdart.dart' hide State; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/constants/constants.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/authentication/repository/models/models.dart'; +import 'package:tsdm_client/features/checkin/bloc/checkin_bloc.dart'; +import 'package:tsdm_client/features/checkin/repository/checkin_repository.dart'; +import 'package:tsdm_client/features/checkin/widgets/checkin_button.dart'; +import 'package:tsdm_client/features/homepage/models/models.dart'; +import 'package:tsdm_client/features/homepage/view/homepage_page.dart'; +import 'package:tsdm_client/features/homepage/widgets/home_dashboard.dart'; +import 'package:tsdm_client/features/profile/widgets/secondary_title_badge.dart'; +import 'package:tsdm_client/features/red_packet/models/models.dart'; +import 'package:tsdm_client/features/red_packet/utils/daily_red_packet_record.dart'; +import 'package:tsdm_client/features/red_packet/widgets/daily_red_packet_button.dart'; +import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; +import 'package:tsdm_client/features/settings/view/settings_page.dart'; +import 'package:tsdm_client/features/theme/cubit/theme_cubit.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; +import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; +import 'package:tsdm_client/widgets/card/post_card/post_card.dart'; +import 'package:tsdm_client/widgets/reply_bar/bloc/reply_bloc.dart'; +import 'package:tsdm_client/widgets/reply_bar/models/reply_types.dart'; +import 'package:tsdm_client/widgets/reply_bar/reply_bar.dart'; +import 'package:tsdm_client/widgets/reply_bar/repository/reply_repository.dart'; + +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); +const _bob = UserLoginInfo(username: 'Bob', uid: 1001); + +/// Authentication repository whose account the test sets. +final class _Auth extends Fake implements AuthenticationRepository { + _Auth(this.currentUser); + + @override + UserLoginInfo? currentUser; + + final _controller = StreamController.broadcast(sync: true); + + @override + Stream get status => _controller.stream; + + @override + int? get effectiveCurrentUid => currentUser?.uid; + + Future close() => _controller.close(); +} + +/// No network at all. +final class _OfflineAdapter implements HttpClientAdapter { + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) => + throw DioException.connectionError(requestOptions: options, reason: 'offline'); + + @override + void close({bool force = false}) {} +} + +/// A page like the thread page: the reply bar is the last child of the body column. +class _Host extends StatelessWidget { + const _Host(this.controller); + + final ReplyBarController controller; + + @override + Widget build(BuildContext context) => Scaffold( + resizeToAvoidBottomInset: false, + body: Column( + children: [ + const Expanded(child: Center(child: Text('thread page'))), + ReplyBar(controller: controller, replyType: ReplyTypes.thread), + ], + ), + ); +} + +Translations get tr => LocaleSettings.instance.currentTranslations; + +Future _settle(WidgetTester tester, {int rounds = 6}) async { + for (var i = 0; i < rounds; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 50)); + } +} + +void main() { + late AppDatabase db; + late StorageProvider storage; + late SettingsRepository settings; + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.zhCn); + }); + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty); + await settings.init(); + }); + + tearDown(() async { + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + group('1. reply editor', () { + Future pumpHost(WidgetTester tester, {required Size size, required EdgeInsets padding}) async { + final controller = ReplyBarController(); + final auth = _Auth(_alice); + // Open for replies, as a loaded thread tells its bar. + final bloc = ReplyBloc(replyRepository: const ReplyRepository())..add(const ReplyThreadClosed(closed: false)); + addTearDown(() async { + await bloc.close(); + await auth.close(); + }); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + RepositoryProvider.value( + value: auth, + child: BlocProvider.value( + value: bloc, + child: TranslationProvider( + child: MediaQuery( + data: MediaQueryData(size: size, padding: padding, viewPadding: padding), + child: MaterialApp(home: _Host(controller)), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('reply-bar-field')), findsOneWidget); + } + + Finder grip() => find.descendant( + of: find.byType(BottomSheet), + matching: find.byWidgetPredicate((w) => w is SizedBox && w.width == 32 && w.height == 4), + ); + + for (final (name, size, padding) in [ + ('phone landscape with a cutout', const Size(792, 384), const EdgeInsets.only(left: 40, right: 40)), + ('phone portrait', const Size(384, 792), const EdgeInsets.only(top: 40)), + ('wide window', const Size(1400, 800), EdgeInsets.zero), + ]) { + testWidgets('$name: the editor spans the page like the bar, no grip above it', (tester) async { + await pumpHost(tester, size: size, padding: padding); + await tester.tap(find.byKey(const ValueKey('reply-bar-field'))); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.unfold_less), findsOneWidget, reason: 'the editor is showing'); + final sheet = tester.getRect(find.byType(BottomSheet)); + expect((sheet.left, sheet.right), (0, size.width), reason: 'no strip of the collapsed bar beside the sheet'); + expect(grip(), findsNothing, reason: 'the collapse button is the one control closing the editor (#139)'); + // The editor and its buttons keep out of the side insets. + final collapse = tester.getRect(find.byIcon(Icons.unfold_less)); + expect(collapse.left, greaterThanOrEqualTo(padding.left)); + expect(collapse.right, lessThanOrEqualTo(size.width - padding.right)); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('the collapse button still closes the editor and the bar is back', (tester) async { + await pumpHost(tester, size: const Size(792, 384), padding: const EdgeInsets.only(left: 40, right: 40)); + await tester.tap(find.byKey(const ValueKey('reply-bar-field'))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.unfold_less)); + await tester.pumpAndSettle(); + expect(find.byType(BottomSheet), findsNothing); + expect(find.byKey(const ValueKey('reply-bar-field')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + }); + + group('2. homepage check-in state', () { + late _Auth auth; + late CheckinBloc bloc; + + setUp(() async { + auth = _Auth(_alice); + for (final u in [_alice, _bob]) { + await storage.saveCookie( + username: u.username!, + uid: u.uid!, + cookie: {'.domains': '{"${u.username}":1}', '${cookiePrefix}_auth': u.username!.toLowerCase()}, + ); + } + bloc = CheckinBloc( + checkinRepository: CheckinRepository(storageProvider: storage), + authenticationRepository: auth, + settingsRepository: settings, + ); + }); + + tearDown(() async { + await bloc.close(); + await auth.close(); + }); + + Future pumpButton(WidgetTester tester) async { + await tester.pumpWidget( + BlocProvider.value( + value: bloc, + child: TranslationProvider( + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => CheckinButton(enableSnackBar: true, label: context.t.homepage.welcome.checkin), + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + Finder labeled(String text) => + find.ancestor(of: find.text(text), matching: find.byWidgetPredicate((w) => w is ButtonStyleButton)); + + testWidgets('a check-in recorded today shows "checked in", disabled', (tester) async { + await storage.updateLastCheckinTime(_alice.uid!, DateTime.now()).run(); + await pumpButton(tester); + expect(labeled(tr.homepage.welcome.checkin), findsOneWidget, reason: 'nothing known before asking'); + + bloc.add(const CheckinStatusRequested()); + await _settle(tester); + expect(bloc.state, isA()); + expect(labeled(tr.homepage.welcome.checkedIn), findsOneWidget); + expect(tester.widget(labeled(tr.homepage.welcome.checkedIn)).onPressed, isNull); + expect(labeled(tr.homepage.welcome.checkin), findsNothing); + }); + + testWidgets('a check-in of yesterday, or none, keeps the check-in button', (tester) async { + await storage.updateLastCheckinTime(_alice.uid!, DateTime.now().subtract(const Duration(days: 1))).run(); + await pumpButton(tester); + bloc.add(const CheckinStatusRequested()); + await _settle(tester); + expect(bloc.state, isA()); + final button = labeled(tr.homepage.welcome.checkin); + expect(button, findsOneWidget); + expect(tester.widget(button).onPressed, isNotNull, reason: 'a tap asks the forum'); + }); + + testWidgets('the record of one account is not shown for another', (tester) async { + await storage.updateLastCheckinTime(_alice.uid!, DateTime.now()).run(); + await pumpButton(tester); + bloc.add(const CheckinStatusRequested()); + await _settle(tester); + expect(bloc.state, isA()); + + auth.currentUser = _bob; + bloc.add(const CheckinStatusRequested()); + await _settle(tester); + expect(bloc.state, isA(), reason: 'Bob has no record'); + expect(labeled(tr.homepage.welcome.checkin), findsOneWidget); + + auth.currentUser = null; + bloc.add(const CheckinStatusRequested()); + await _settle(tester); + expect(bloc.state, isA()); + }); + }); + + group('2. homepage daily red packet record', () { + const config = DailyRedPacketConfig(entry: 2, dateFlag: '20260928', from: '', bless: '', unit: 'coins'); + final today = dailyRedPacketDateFlag(DateTime.now()); + + Future pumpEntry( + WidgetTester tester, { + required int? uid, + DailyRedPacketConfig? packet, + DailyRedPacketClaim? claim, + }) async { + await tester.pumpWidget( + TranslationProvider( + child: MaterialApp( + home: Scaffold( + body: DailyRedPacketEntry( + uid: uid, + config: packet, + formHash: 'XXXXXXXX', + onCheck: () {}, + claim: claim, + ), + ), + ), + ), + ); + await _settle(tester, rounds: 3); + } + + test('the date flag is the local calendar day, like the forum writes it', () { + expect(dailyRedPacketDateFlag(DateTime(2026, 9, 28)), '20260928'); + expect(dailyRedPacketDateFlag(DateTime(2026, 1, 5)), '20260105'); + expect(dailyRedPacketClaimedToday('20260928', now: DateTime(2026, 9, 28, 23, 59)), isTrue); + expect(dailyRedPacketClaimedToday('20260928', now: DateTime(2026, 9, 29)), isFalse); + expect(dailyRedPacketClaimedToday(null, now: DateTime(2026, 9, 28)), isFalse); + expect(dailyRedPacketClaimKey(1000), 'dailyRedPacketClaimed.1000'); + }); + + testWidgets('no packet on the page and a claim recorded today: "claimed"', (tester) async { + await storage.saveString(dailyRedPacketClaimKey(_alice.uid!), today); + await pumpEntry(tester, uid: _alice.uid); + expect(find.byKey(const ValueKey('dailyRedPacket-recorded')), findsOneWidget); + expect(find.text(tr.redPacket.daily.claimedToday), findsOneWidget); + expect(find.text(tr.redPacket.daily.unavailable), findsNothing); + }); + + testWidgets('a record of another day, or of another account, keeps "none now"', (tester) async { + await storage.saveString(dailyRedPacketClaimKey(_alice.uid!), '20200101'); + await storage.saveString(dailyRedPacketClaimKey(_bob.uid!), today); + await pumpEntry(tester, uid: _alice.uid); + expect(find.text(tr.redPacket.daily.unavailable), findsOneWidget); + expect(find.byKey(const ValueKey('dailyRedPacket-recorded')), findsNothing); + }); + + testWidgets('a packet on the page wins over the record', (tester) async { + await storage.saveString(dailyRedPacketClaimKey(_alice.uid!), today); + await pumpEntry(tester, uid: _alice.uid, packet: config); + expect(find.text(tr.redPacket.daily.tooltip), findsOneWidget, reason: 'claimable'); + expect(find.byKey(const ValueKey('dailyRedPacket-recorded')), findsNothing); + }); + + testWidgets('a claim of this run is recorded for the account', (tester) async { + var claims = 0; + await pumpEntry( + tester, + uid: _alice.uid, + packet: config, + claim: (formHash) => AsyncEither(() async { + claims++; + return const Right(DailyRedPacketResult(ok: true, amount: '3', unit: 'coins')); + }), + ); + await tester.tap(find.text(tr.redPacket.daily.tooltip)); + await _settle(tester, rounds: 3); + expect(claims, 1); + expect(find.text(tr.redPacket.daily.claimedToday), findsOneWidget); + expect(await storage.getString(dailyRedPacketClaimKey(_alice.uid!)), '20260928'); + expect(await storage.getString(dailyRedPacketClaimKey(_bob.uid!)), isNull); + }); + + testWidgets('"already claimed" from the server is recorded too', (tester) async { + await pumpEntry( + tester, + uid: _alice.uid, + packet: config, + claim: (formHash) => + AsyncEither(() async => const Right(DailyRedPacketResult(ok: false, already: true, error: 'done'))), + ); + await tester.tap(find.text(tr.redPacket.daily.tooltip)); + await _settle(tester, rounds: 3); + expect(await storage.getString(dailyRedPacketClaimKey(_alice.uid!)), '20260928'); + }); + + testWidgets('a failed claim records nothing', (tester) async { + await pumpEntry( + tester, + uid: _alice.uid, + packet: config, + claim: (formHash) => AsyncEither(() async => const Right(DailyRedPacketResult(ok: false, error: 'nope'))), + ); + await tester.tap(find.text(tr.redPacket.daily.tooltip)); + await _settle(tester, rounds: 3); + expect(await storage.getString(dailyRedPacketClaimKey(_alice.uid!)), isNull); + expect(find.text(tr.redPacket.daily.tooltip), findsOneWidget, reason: 'still claimable'); + }); + }); + + group('3. author badges', () { + test('the floor shows both badges the same height', () { + expect(postAuthorSecondBadgeWidth(360), SecondaryTitleBadge.widthFor(authorBadgeHeight)); + expect(SecondaryTitleBadge.heightFor(postAuthorSecondBadgeWidth(360)), closeTo(authorBadgeHeight, 0.001)); + expect(postAuthorSecondBadgeWidth(40), 40, reason: 'a narrow floor never overflows'); + expect(SecondaryTitleBadge.widthFor(100), 184); + expect(SecondaryTitleBadge.widthFor(50), 92); + }); + }); + + group('4. second round', () { + // The settings page reads the database schema version and the image cache (debug section, avatar row); the + // image cache is disposed by the getIt reset of the file. + late SettingsBloc settingsBloc; + late ThemeCubit theme; + + setUp(() { + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + theme = ThemeCubit(); + getIt + ..registerSingleton(db) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ); + }); + + tearDown(() async { + await settingsBloc.close(); + await theme.close(); + }); + + test('phones keep the compact homepage at any width, desktop windows grow', () { + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + for (final width in [360.0, 792.0, 1280.0, 1600.0]) { + expect(homeLayoutFor(width, platform), HomeLayout.compact, reason: '$platform $width'); + } + } + for (final platform in [TargetPlatform.windows, TargetPlatform.linux, TargetPlatform.macOS]) { + expect(homeLayoutFor(500, platform), HomeLayout.compact, reason: '$platform narrow'); + expect(homeLayoutFor(700, platform), HomeLayout.medium, reason: '$platform medium'); + expect(homeLayoutFor(1280, platform), HomeLayout.wide, reason: '$platform wide'); + } + }); + + testWidgets('the greeting card of a landscape phone: compact, all four actions on one row, no overflow', ( + tester, + ) async { + final auth = _Auth(_alice); + final checkin = CheckinBloc( + checkinRepository: CheckinRepository(storageProvider: storage), + authenticationRepository: auth, + settingsRepository: settings, + ); + addTearDown(() async { + await checkin.close(); + await auth.close(); + }); + const size = Size(792, 384); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + BlocProvider.value( + value: checkin, + child: TranslationProvider( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: HomeGreetingCard( + username: 'Alice', + uid: _alice.uid, + forumStatus: const ForumStatus.empty(), + dailyRedPacket: null, + formHash: 'XXXXXXXX', + compact: true, + ), + ), + ), + ), + ), + ), + ); + await _settle(tester, rounds: 3); + final checkinRect = tester.getRect(find.text(tr.homepage.welcome.checkin)); + final packetRect = tester.getRect(find.text(tr.redPacket.daily.unavailable)); + final activitiesRect = tester.getRect(find.text(tr.activitiesPage.title)); + final medalsRect = tester.getRect(find.text(tr.medalTitleHub.title)); + expect(checkinRect.top, closeTo(packetRect.top, 1), reason: 'check-in and red packet on one row'); + expect(activitiesRect.top, closeTo(packetRect.top, 1), reason: 'activities on the same row (feedback 113)'); + expect(medalsRect.top, closeTo(packetRect.top, 1), reason: 'medals and titles on the same row'); + expect(medalsRect.right, lessThanOrEqualTo(size.width), reason: 'inside the card'); + expect(tester.getRect(find.byType(HomeGreetingCard)).height, lessThan(200), reason: 'one row of actions'); + expect(tester.takeException(), isNull); + }); + + testWidgets('the greeting card of a portrait phone keeps two rows of actions', (tester) async { + final auth = _Auth(_alice); + final checkin = CheckinBloc( + checkinRepository: CheckinRepository(storageProvider: storage), + authenticationRepository: auth, + settingsRepository: settings, + ); + addTearDown(() async { + await checkin.close(); + await auth.close(); + }); + tester.view.physicalSize = const Size(384, 792); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + BlocProvider.value( + value: checkin, + child: TranslationProvider( + child: MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: HomeGreetingCard( + username: 'Alice', + uid: _alice.uid, + forumStatus: const ForumStatus.empty(), + dailyRedPacket: null, + formHash: 'XXXXXXXX', + compact: true, + ), + ), + ), + ), + ), + ), + ); + await _settle(tester, rounds: 3); + final packetRect = tester.getRect(find.text(tr.redPacket.daily.unavailable)); + final activitiesRect = tester.getRect(find.text(tr.activitiesPage.title)); + expect(tester.getRect(find.text(tr.homepage.welcome.checkin)).top, closeTo(packetRect.top, 1)); + expect(activitiesRect.top, greaterThan(packetRect.bottom), reason: 'the entries wrap below on a portrait phone'); + expect(tester.takeException(), isNull); + }); + + for (final scale in [1.0, 2.0]) { + testWidgets('${scale}x text: the theme mode switch is in one line with its title', (tester) async { + tester.view.physicalSize = const Size(384, 792); + tester.view.devicePixelRatio = 1; + tester.platformDispatcher.textScaleFactorTestValue = scale; + addTearDown(() { + tester.view.reset(); + tester.platformDispatcher.clearTextScaleFactorTestValue(); + }); + await tester.pumpWidget( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: theme), + ], + child: TranslationProvider(child: const MaterialApp(home: SettingsPage())), + ), + ); + await _settle(tester, rounds: 3); + final title = tester.getRect(find.text(tr.settingsPage.appearanceSection.themeMode.title)); + debugPrint('DBG switchers ${find.byType(SegmentedButton).evaluate().length}'); + final switcher = tester.getRect(find.byType(SegmentedButton)); + expect(switcher.left, greaterThan(title.right), reason: 'at the end of the row, not below the text'); + expect(switcher.top, lessThan(title.bottom + 8), reason: 'in the same row'); + expect(switcher.right, lessThanOrEqualTo(384), reason: 'inside the window'); + expect(tester.takeException(), isNull); + }); + } + }); +} diff --git a/test/regression/test_173_thread_content_scale_test.dart b/test/regression/test_173_thread_content_scale_test.dart new file mode 100644 index 000000000..55da638ee --- /dev/null +++ b/test/regression/test_173_thread_content_scale_test.dart @@ -0,0 +1,269 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/features/authentication/repository/authentication_repository.dart'; +import 'package:tsdm_client/features/blocking/cubit/user_block_cubit.dart'; +import 'package:tsdm_client/features/blocking/repository/user_block_repository.dart'; +import 'package:tsdm_client/features/blocking/utils/thread_author_cache.dart'; +import 'package:tsdm_client/features/favorite/repository/favorite_repository.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_state_cubit.dart'; +import 'package:tsdm_client/features/notification/repository/notification_info_repository.dart'; +import 'package:tsdm_client/features/settings/bloc/settings_bloc.dart'; +import 'package:tsdm_client/features/settings/repositories/settings_repository.dart'; +import 'package:tsdm_client/features/thread/v1/view/thread_page.dart'; +import 'package:tsdm_client/i18n/strings.g.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/models/models.dart'; +import 'package:tsdm_client/shared/providers/cookie_provider/cookie_provider.dart'; +import 'package:tsdm_client/shared/providers/image_cache_provider/image_cache_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_error_saver.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; +import 'package:tsdm_client/shared/repositories/fragments_repository/fragments_repository.dart'; +import 'package:tsdm_client/widgets/reply_bar/reply_bar.dart'; + +/// GitHub #137: the thread content scale setting enlarges the floors of the thread page on top of the global text +/// scale, without touching the app bar or the reply bar, and the combined scale is capped. +/// +/// The thread page is synthetic (grounded in the Discuz template), no real account or network. +const _alice = UserLoginInfo(username: 'Alice', uid: 1000); + +/// Notification bloc holding a fixed state; the thread page only reads it. +final class _Notifications extends Fake implements NotificationBloc { + _Notifications(this.state); + + @override + void add(NotificationEvent event) {} + + @override + final NotificationState state; + + @override + Stream get stream => const Stream.empty(); + + @override + bool get isClosed => false; + + @override + Future close() async {} +} + +/// Never answers: avatars are not loaded in these tests. +final class _OfflineAdapter implements HttpClientAdapter { + @override + Future fetch(RequestOptions options, Stream? requestStream, Future? cancelFuture) => + throw DioException.connectionError(requestOptions: options, reason: 'offline'); + + @override + void close({bool force = false}) {} +} + +/// Serves one thread page for every request. +final class _ThreadAdapter implements HttpClientAdapter { + _ThreadAdapter(this.page); + + final String page; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async => ResponseBody.fromString( + page, + 200, + headers: { + Headers.contentTypeHeader: ['text/html; charset=utf-8'], + }, + ); + + @override + void close({bool force = false}) {} +} + +String _floor({required int pid, required int floor, required int uid, required String name}) => + ''' +
+ + +
+
floor $floor text
'''; + +String _threadPage(List floors) => + ''' + +

Scaled title

+${floors.join('\n')} +
+'''; + +void main() { + late AppDatabase db; + late StorageProvider storage; + late SettingsRepository settings; + late SettingsBloc settingsBloc; + late UserBlockRepository blocks; + late AuthenticationRepository auth; + + setUpAll(() async { + talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false)); + await LocaleSettings.setLocale(AppLocale.en); + }); + + setUp(() async { + db = AppDatabase(NativeDatabase.memory()); + storage = StorageProvider(db, {}, {}); + settings = SettingsRepository(storage); + getIt + ..registerSingleton(storage) + ..registerSingleton(settings) + ..registerSingleton(NetErrorSaver()) + ..registerSingleton(CookieProvider.buildEmpty()) + ..registerFactory(CookieProvider.buildEmpty, instanceName: ServiceKeys.empty); + await settings.init(); + getIt + ..registerSingleton( + ImageCacheProvider(NetClientProvider.buildNoCookie(dio: Dio()..httpClientAdapter = _OfflineAdapter())), + ) + ..registerFactory( + () => NetClientProvider.build( + dio: Dio(BaseOptions(baseUrl: baseUrl)) + ..httpClientAdapter = _ThreadAdapter( + _threadPage([_floor(pid: 1, floor: 1, uid: _alice.uid!, name: 'Alice')]), + ), + ), + ); + settingsBloc = SettingsBloc(settingsRepository: settings, fragmentsRepository: FragmentsRepository()); + blocks = UserBlockRepository(storage); + auth = AuthenticationRepository(user: _alice); + ThreadAuthorCache.clear(); + }); + + tearDown(() async { + await settingsBloc.close(); + await blocks.dispose(); + await getIt.get().dispose(); + await getIt.reset(); + await settings.dispose(); + await db.close(); + }); + + /// Pump the thread page under the providers of the app, with [globalScale] as the app wide linear text scale. + Future pump(WidgetTester tester, {double globalScale = 1}) async { + final info = NotificationInfoRepository(); + final counts = NotificationStateCubit(info); + final cubit = UserBlockCubit(repository: blocks, currentUid: () => _alice.uid, authStatus: auth.status); + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const ThreadPage( + threadID: '1264975', + findPostID: null, + pageNumber: '1', + overrideReverseOrder: false, + overrideWithExactOrder: null, + ), + ), + ], + ); + addTearDown(() async { + router.dispose(); + await cubit.close(); + await info.dispose(); + await counts.close(); + }); + await tester.pumpWidget( + TranslationProvider( + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider.value(value: auth), + RepositoryProvider(create: (_) => FavoriteRepository()), + ], + child: MultiBlocProvider( + providers: [ + BlocProvider.value(value: settingsBloc), + BlocProvider.value(value: cubit), + BlocProvider.value( + value: _Notifications(const NotificationState(status: NotificationStatus.success)), + ), + BlocProvider.value(value: counts), + ], + // Like the app, the global scale is a linear scaler above the app (`lib/app.dart`). + child: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(globalScale)), + child: MaterialApp.router(routerConfig: router, scaffoldMessengerKey: snackbarKey), + ), + ), + ), + ), + ); + await settle(tester); + } + + /// The text scale factor in effect where [finder] is built. + double scaleAt(WidgetTester tester, Finder finder) => MediaQuery.textScalerOf(tester.element(finder.first)).scale(1); + + Finder floorText() => find.textContaining('floor 1 text', findRichText: true); + // Measured at the app bar itself: the app bar clamps the scale of its own title (1.34 at most). + Finder appBarTitle() => find.byType(AppBar); + + Future set(WidgetTester tester, SettingsKeys key, double value) async { + await tester.runAsync(() => settings.setValue(key, value)); + await settle(tester); + } + + testWidgets('the default setting leaves the floors at the global text scale', (tester) async { + await pump(tester); + expect(floorText(), findsWidgets); + expect(find.descendant(of: appBarTitle(), matching: find.text('Scaled title')), findsOneWidget); + expect(scaleAt(tester, floorText()), 1.0); + expect(scaleAt(tester, appBarTitle()), 1.0); + }); + + testWidgets('the setting scales the floors but not the app bar nor the reply bar', (tester) async { + await pump(tester); + await set(tester, SettingsKeys.threadContentScale, 2); + expect(scaleAt(tester, floorText()), 2.0); + expect(scaleAt(tester, appBarTitle()), 1.0); + expect(scaleAt(tester, find.byType(ReplyBar)), 1.0); + + // Back to no extra scale, live. + await set(tester, SettingsKeys.threadContentScale, 1); + expect(scaleAt(tester, floorText()), 1.0); + }); + + testWidgets('the setting multiplies the global text scale and the product is capped', (tester) async { + await pump(tester, globalScale: 1.5); + expect(scaleAt(tester, appBarTitle()), 1.5); + expect(scaleAt(tester, floorText()), 1.5); + + await set(tester, SettingsKeys.threadContentScale, 1.6); + expect(scaleAt(tester, floorText()), closeTo(2.4, 0.001)); + expect(scaleAt(tester, appBarTitle()), 1.5); + + await set(tester, SettingsKeys.threadContentScale, 2); + expect(scaleAt(tester, floorText()), threadContentMaxTextScale, reason: '1.5 * 2 exceeds the cap'); + expect(scaleAt(tester, find.byType(ReplyBar)), 1.5); + }); +} + +/// Let database work and futures finish outside the fake zone, then build the frames it caused. +Future settle(WidgetTester tester) async { + for (var i = 0; i < 5; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(const Duration(milliseconds: 20)); + } +} diff --git a/test/regression/test_174_desktop_back_handler_test.dart b/test/regression/test_174_desktop_back_handler_test.dart new file mode 100644 index 000000000..386d7b6f7 --- /dev/null +++ b/test/regression/test_174_desktop_back_handler_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tsdm_client/widgets/desktop_back_handler.dart'; + +/// GitHub #138: on desktop, `Esc` and the mouse back button go back one page, and only when that is what a user +/// would expect: never from the root page, never out of a text input, and dialogs or persistent sheets close first. +void main() { + setUp(() => DesktopBackHandler.enabledOverride = true); + tearDown(() => DesktopBackHandler.enabledOverride = null); + + /// Number of times the root page refused a pop. + var rootPopAttempts = 0; + + Widget app({bool secondCanPop = true, ValueChanged? onSecondPopInvoked}) => MaterialApp( + builder: (context, child) => DesktopBackHandler(child: child ?? const SizedBox.shrink()), + home: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) => rootPopAttempts++, + child: Scaffold( + appBar: AppBar(title: const Text('Root')), + body: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PopScope( + canPop: secondCanPop, + onPopInvokedWithResult: (didPop, _) => onSecondPopInvoked?.call(didPop), + child: Scaffold( + appBar: AppBar(title: const Text('Second')), + body: Builder( + builder: (context) => Column( + children: [ + const TextField(key: Key('field')), + TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => const AlertDialog(title: Text('Dialog')), + ), + child: const Text('dialog'), + ), + TextButton( + onPressed: () => Scaffold.of(context).showBottomSheet( + (_) => const SizedBox(height: 100, child: Text('Sheet')), + ), + child: const Text('sheet'), + ), + ], + ), + ), + ), + ), + ), + ), + child: const Text('push'), + ), + ), + ), + ), + ); + + Future pushSecond(WidgetTester tester) async { + await tester.tap(find.text('push')); + await tester.pumpAndSettle(); + expect(find.text('Second'), findsOneWidget); + } + + Future escape(WidgetTester tester) async { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + } + + Future mouseBack(WidgetTester tester) async { + final pointer = TestPointer(1, PointerDeviceKind.mouse, null, kBackMouseButton); + await tester.sendEventToBinding(pointer.down(tester.getCenter(find.byType(Scaffold).last))); + await tester.sendEventToBinding(pointer.up()); + await tester.pumpAndSettle(); + } + + setUp(() => rootPopAttempts = 0); + + testWidgets('Esc and the mouse back button pop a page that can go back', (tester) async { + await tester.pumpWidget(app()); + await pushSecond(tester); + await escape(tester); + expect(find.text('Second'), findsNothing); + expect(find.text('Root'), findsOneWidget); + + await pushSecond(tester); + await mouseBack(tester); + expect(find.text('Second'), findsNothing); + expect(find.text('Root'), findsOneWidget); + expect(rootPopAttempts, 0); + }); + + testWidgets('nothing happens on the root page', (tester) async { + await tester.pumpWidget(app()); + await escape(tester); + await mouseBack(tester); + expect(find.text('Root'), findsOneWidget); + expect(rootPopAttempts, 0, reason: 'the exit-app PopScope of the root page is never poked'); + }); + + testWidgets('a focused text input keeps Esc and the mouse back button', (tester) async { + await tester.pumpWidget(app()); + await pushSecond(tester); + await tester.tap(find.byKey(const Key('field'))); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus?.context?.widget, isNot(isA())); + + await escape(tester); + expect(find.text('Second'), findsOneWidget); + await mouseBack(tester); + expect(find.text('Second'), findsOneWidget); + expect(rootPopAttempts, 0); + }); + + testWidgets('Esc closes an open dialog first and the page only afterwards', (tester) async { + await tester.pumpWidget(app()); + await pushSecond(tester); + await tester.tap(find.text('dialog')); + await tester.pumpAndSettle(); + expect(find.text('Dialog'), findsOneWidget); + + await escape(tester); + expect(find.text('Dialog'), findsNothing); + expect(find.text('Second'), findsOneWidget); + + await escape(tester); + expect(find.text('Second'), findsNothing); + }); + + testWidgets('a persistent bottom sheet closes before the page', (tester) async { + await tester.pumpWidget(app()); + await pushSecond(tester); + await tester.tap(find.text('sheet')); + await tester.pumpAndSettle(); + expect(find.text('Sheet'), findsOneWidget); + + await escape(tester); + expect(find.text('Sheet'), findsNothing); + expect(find.text('Second'), findsOneWidget); + + await mouseBack(tester); + expect(find.text('Second'), findsNothing); + }); + + testWidgets('a page refusing to pop keeps deciding through its PopScope', (tester) async { + final invocations = []; + await tester.pumpWidget(app(secondCanPop: false, onSecondPopInvoked: invocations.add)); + await pushSecond(tester); + await escape(tester); + expect(find.text('Second'), findsOneWidget); + expect(invocations, [false]); + }); + + testWidgets('disabled outside desktop', (tester) async { + DesktopBackHandler.enabledOverride = false; + await tester.pumpWidget(app()); + await pushSecond(tester); + await escape(tester); + await mouseBack(tester); + expect(find.text('Second'), findsOneWidget); + }); +}