feat(mobile): UI-01 theme system, shared components and navigation

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 14:55:29 +08:00
parent 8f4ad11bd5
commit c94b9e5e58
27 changed files with 2067 additions and 555 deletions
+78 -78
View File
@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/albums/providers.dart';
import '../features/uploads/engine_scope.dart';
@@ -31,7 +32,8 @@ class QueueTab extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final counts = ref.watch(taskCountsProvider);
final autoRetryable = (counts[UploadTaskState.retryableFailed] ?? 0) +
final autoRetryable =
(counts[UploadTaskState.retryableFailed] ?? 0) +
(counts[UploadTaskState.waitingNetwork] ?? 0);
return Scaffold(
appBar: AppBar(
@@ -40,8 +42,7 @@ class QueueTab extends ConsumerWidget {
IconButton(
tooltip: l10n.serverHistoryEntry,
icon: const Icon(Icons.history_outlined),
onPressed: () =>
Navigator.of(context).pushNamed('/server-history'),
onPressed: () => Navigator.of(context).pushNamed('/server-history'),
),
],
),
@@ -77,10 +78,15 @@ class _FilterBar extends ConsumerWidget {
children: [
Row(
children: [
Icon(Icons.filter_list,
size: 18, color: theme.colorScheme.onSurfaceVariant),
Icon(
Icons.filter_list,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(child: _AlbumDropdown(filter: filter, albums: albums)),
Expanded(
child: _AlbumDropdown(filter: filter, albums: albums),
),
],
),
const SizedBox(height: 4),
@@ -93,9 +99,8 @@ class _FilterBar extends ConsumerWidget {
ChoiceChip(
label: Text(_timeLabel(l10n, time)),
selected: filter.time == time,
onSelected: (_) => ref
.read(queueFilterProvider.notifier)
.setTime(time),
onSelected: (_) =>
ref.read(queueFilterProvider.notifier).setTime(time),
visualDensity: VisualDensity.compact,
),
],
@@ -159,10 +164,9 @@ class _AlbumDropdown extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final knownIds = albums.map((a) => a.id).toSet();
final value =
filter.albumId != null && knownIds.contains(filter.albumId)
? filter.albumId
: null;
final value = filter.albumId != null && knownIds.contains(filter.albumId)
? filter.albumId
: null;
return DropdownButton<String?>(
value: value,
isExpanded: true,
@@ -224,8 +228,7 @@ class _TaskListView extends ConsumerWidget {
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: tasks.length,
itemBuilder: (context, index) =>
_TaskTile(task: tasks[index]),
itemBuilder: (context, index) => _TaskTile(task: tasks[index]),
);
},
);
@@ -250,7 +253,8 @@ class _TaskTile extends ConsumerWidget {
final albumName = album != null
? albumDisplayTitle(album, l10n.localeName)
: task.albumId;
final failedLike = task.state == UploadTaskState.preparationFailed ||
final failedLike =
task.state == UploadTaskState.preparationFailed ||
task.state == UploadTaskState.retryableFailed ||
task.state == UploadTaskState.waitingNetwork;
@@ -258,11 +262,7 @@ class _TaskTile extends ConsumerWidget {
leading: _Thumb(
path: task.cleanedAt == null ? task.sourceFilePath : null,
),
title: Text(
task.filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
title: Text(task.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -276,8 +276,9 @@ class _TaskTile extends ConsumerWidget {
albumName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
@@ -287,12 +288,13 @@ class _TaskTile extends ConsumerWidget {
Text(
task.attempt > 0
? '${l10n.messageForTaskError(task.lastError!)} · '
'${l10n.queueAttemptCount(task.attempt)}'
'${l10n.queueAttemptCount(task.attempt)}'
: l10n.messageForTaskError(task.lastError!),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (task.state == UploadTaskState.uploading ||
@@ -318,18 +320,18 @@ class _Thumb extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget placeholder() => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.image_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
);
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.image_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
);
if (path == null) return placeholder();
return ClipRRect(
borderRadius: BorderRadius.circular(6),
@@ -358,35 +360,33 @@ class _StateChip extends StatelessWidget {
final theme = Theme.of(context);
final (color, onColor) = switch (state) {
UploadTaskState.preparing => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
UploadTaskState.preparationFailed ||
UploadTaskState.retryableFailed => (
theme.colorScheme.errorContainer,
theme.colorScheme.onErrorContainer
),
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant,
),
UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => (
theme.colorScheme.errorContainer,
theme.colorScheme.onErrorContainer,
),
UploadTaskState.queued => (
theme.colorScheme.secondaryContainer,
theme.colorScheme.onSecondaryContainer
),
UploadTaskState.uploading ||
UploadTaskState.uploadedConfirming => (
theme.colorScheme.primaryContainer,
theme.colorScheme.onPrimaryContainer
),
theme.colorScheme.secondaryContainer,
theme.colorScheme.onSecondaryContainer,
),
UploadTaskState.uploading || UploadTaskState.uploadedConfirming => (
theme.colorScheme.primaryContainer,
theme.colorScheme.onPrimaryContainer,
),
UploadTaskState.completed => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer,
),
UploadTaskState.waitingNetwork => (
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer
),
theme.colorScheme.tertiaryContainer,
theme.colorScheme.onTertiaryContainer,
),
UploadTaskState.cancelled => (
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant
),
theme.colorScheme.surfaceContainerHighest,
theme.colorScheme.onSurfaceVariant,
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
@@ -458,9 +458,7 @@ class _TaskActions extends ConsumerWidget {
icon: const Icon(Icons.refresh),
onPressed: account == null
? null
: () => ref
.read(uploadEngineProvider)
.retryNow(account, task.id),
: () => ref.read(uploadEngineProvider).retryNow(account, task.id),
);
case UploadTaskState.queued:
case UploadTaskState.preparing:
@@ -495,15 +493,11 @@ class _TaskActions extends ConsumerWidget {
if (sourcePath == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(uploadPreparerProvider).retry(
accountId: account,
taskId: task.id,
sourcePath: sourcePath,
);
await ref
.read(uploadPreparerProvider)
.retry(accountId: account, taskId: task.id, sourcePath: sourcePath);
} on Object {
messenger.showSnackBar(
SnackBar(content: Text(l10n.errorGeneric)),
);
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
}
}
@@ -541,13 +535,17 @@ class _BatchRetryBar extends ConsumerWidget {
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Row(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.onErrorContainer),
Icon(
Icons.error_outline,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'${l10n.queueStateFailed} · $failed',
style: theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onErrorContainer),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
FilledButton.icon(
@@ -572,7 +570,9 @@ class _BatchRetryBar extends ConsumerWidget {
final account = ref.read(sessionAccountProvider).value;
if (account == null) return;
final engine = ref.read(uploadEngineProvider);
final tasks = await ref.read(taskStoreProvider).listByAccount(
final tasks = await ref
.read(taskStoreProvider)
.listByAccount(
account,
states: const {
UploadTaskState.retryableFailed,