UI Phase 2: cosmetic leftovers — compact Profile photo card, fit Users table

- Profile Photo card: replace the tall centered avatar+buttons stack with a
  compact horizontal row (avatar left, caption + Upload/Camera buttons right),
  roughly halving the card height and reclaiming the dead vertical space.
- TasQColumn gains an optional `minWidth`; the adaptive table now sums per-column
  minimums instead of a flat 200 px each. User Management sets narrow widths on
  its short columns (Role/Offices/Status/Last active) so all six columns fit at
  desktop width without the horizontal scroll that was clipping the Status
  column. Other tables keep the 200 px default (unchanged).

Verified: flutter analyze lib clean; flutter build web green; visual QA
(Profile, User Management) in dark.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-09-26 23:35:19 +08:00
parent 4685430406
commit e7706058b7
3 changed files with 74 additions and 45 deletions
@@ -199,10 +199,12 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
), ),
TasQColumn<Profile>( TasQColumn<Profile>(
header: 'Role', header: 'Role',
minWidth: 120,
cellBuilder: (context, profile) => Text(_roleLabel(profile.role)), cellBuilder: (context, profile) => Text(_roleLabel(profile.role)),
), ),
TasQColumn<Profile>( TasQColumn<Profile>(
header: 'Offices', header: 'Offices',
minWidth: 100,
cellBuilder: (context, profile) { cellBuilder: (context, profile) {
final officesAssigned = officeCountByUser[profile.id] ?? 0; final officesAssigned = officeCountByUser[profile.id] ?? 0;
return Text(officesAssigned == 0 ? 'None' : '$officesAssigned'); return Text(officesAssigned == 0 ? 'None' : '$officesAssigned');
@@ -210,6 +212,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
), ),
TasQColumn<Profile>( TasQColumn<Profile>(
header: 'Status', header: 'Status',
minWidth: 120,
cellBuilder: (context, profile) { cellBuilder: (context, profile) {
final statusAsync = ref.watch(adminUserStatusProvider(profile.id)); final statusAsync = ref.watch(adminUserStatusProvider(profile.id));
return statusAsync.when( return statusAsync.when(
@@ -224,6 +227,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
), ),
TasQColumn<Profile>( TasQColumn<Profile>(
header: 'Last active', header: 'Last active',
minWidth: 150,
cellBuilder: (context, profile) { cellBuilder: (context, profile) {
final lastActive = lastActiveByUser[profile.id]; final lastActive = lastActiveByUser[profile.id];
return Text(_formatLastActiveLabel(lastActive)); return Text(_formatLastActiveLabel(lastActive));
+59 -44
View File
@@ -280,54 +280,69 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
children: [ children: [
const AppSectionHeader('Profile Photo', padding: EdgeInsets.zero), const AppSectionHeader('Profile Photo', padding: EdgeInsets.zero),
const SizedBox(height: 16), const SizedBox(height: 16),
Center(
child: Stack(
children: [
Hero(
tag: 'profile-avatar',
child: CircleAvatar(
radius: 56,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Icon(
Icons.person,
size: 48,
color: colors.onSurfaceVariant,
)
: null,
),
),
if (_uploadingAvatar)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
],
),
),
const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
FilledButton.tonalIcon( Stack(
onPressed: _uploadingAvatar children: [
? null Hero(
: () => _pickAvatar(ImageSource.gallery), tag: 'profile-avatar',
icon: const Icon(Icons.photo_library), child: CircleAvatar(
label: const Text('Upload'), radius: 44,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Icon(
Icons.person,
size: 40,
color: colors.onSurfaceVariant,
)
: null,
),
),
if (_uploadingAvatar)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
),
],
), ),
if (!kIsWeb) ...[ const SizedBox(width: 20),
const SizedBox(width: 12), Expanded(
FilledButton.tonalIcon( child: Column(
onPressed: _uploadingAvatar crossAxisAlignment: CrossAxisAlignment.start,
? null children: [
: () => _pickAvatar(ImageSource.camera), Text(
icon: const Icon(Icons.camera_alt), 'A clear headshot helps teammates recognize you.',
label: const Text('Camera'), style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _uploadingAvatar
? null
: () => _pickAvatar(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Upload'),
),
if (!kIsWeb)
FilledButton.tonalIcon(
onPressed: _uploadingAvatar
? null
: () => _pickAvatar(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Camera'),
),
],
),
],
), ),
], ),
], ],
), ),
], ],
+11 -1
View File
@@ -18,6 +18,7 @@ class TasQColumn<T> {
required this.cellBuilder, required this.cellBuilder,
this.technical = false, this.technical = false,
this.hideOnMedium = false, this.hideOnMedium = false,
this.minWidth,
}); });
/// The column header text. /// The column header text.
@@ -33,6 +34,11 @@ class TasQColumn<T> {
/// Use this for lower-priority columns (e.g. Timestamp, Status) that /// Use this for lower-priority columns (e.g. Timestamp, Status) that
/// would otherwise force a horizontal scrollbar on tablet viewports. /// would otherwise force a horizontal scrollbar on tablet viewports.
final bool hideOnMedium; final bool hideOnMedium;
/// Minimum comfortable width for this column, in logical pixels. When null a
/// default of 200 is used. Set a smaller value for short columns (a count, a
/// status pill, a short enum) so the table fits without a horizontal scroll.
final double? minWidth;
} }
/// Builds a mobile tile for [TasQAdaptiveList]. /// Builds a mobile tile for [TasQAdaptiveList].
@@ -380,9 +386,13 @@ class TasQAdaptiveList<T> extends StatelessWidget {
const double colSpacing = 20.0; const double colSpacing = 20.0;
final actionsColumnCount = rowActions == null ? 0 : 1; final actionsColumnCount = rowActions == null ? 0 : 1;
final totalCols = visibleColumns.length + actionsColumnCount; final totalCols = visibleColumns.length + actionsColumnCount;
final columnsMinWidth = visibleColumns.fold<double>(
0.0,
(sum, c) => sum + (c.minWidth ?? colMinW),
);
final minColumnsWidth = final minColumnsWidth =
hMarginTotal + hMarginTotal +
(visibleColumns.length * colMinW) + columnsMinWidth +
(actionsColumnCount * actMinW) + (actionsColumnCount * actMinW) +
(math.max(0, totalCols - 1) * colSpacing); (math.max(0, totalCols - 1) * colSpacing);
final tableWidth = math.max(contentWidth, minColumnsWidth); final tableWidth = math.max(contentWidth, minColumnsWidth);