Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba889a38be | |||
| 7fce19a37c | |||
| 9f524e158d | |||
| 46d0427901 | |||
| 45294ade31 | |||
| 89c31f1904 | |||
| 9f1d2317af | |||
| c3d9cc273f | |||
| 63e01389e8 | |||
| 5ca5856f15 | |||
| a337c3fda3 | |||
| fc1c7cade2 | |||
| 0971433c7a | |||
| 844f68d376 |
@@ -61,7 +61,7 @@ android {
|
|||||||
val variant = this
|
val variant = this
|
||||||
outputs.all {
|
outputs.all {
|
||||||
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
val output = this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||||
output?.outputFileName = "Fabled-${variant.versionName}.${variant.versionCode}.apk"
|
output?.outputFileName = "Fabled-${variant.versionCode}.apk"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-8
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
|
||||||
import 'core/constants.dart';
|
import 'core/constants.dart';
|
||||||
import 'core/exceptions.dart';
|
import 'core/exceptions.dart';
|
||||||
import 'core/theme.dart';
|
import 'core/theme.dart';
|
||||||
@@ -15,6 +17,7 @@ import 'providers/update_provider.dart';
|
|||||||
import 'providers/tasks_provider.dart';
|
import 'providers/tasks_provider.dart';
|
||||||
import 'screens/auth/login_screen.dart';
|
import 'screens/auth/login_screen.dart';
|
||||||
import 'screens/briefing/briefing_screen.dart';
|
import 'screens/briefing/briefing_screen.dart';
|
||||||
|
import 'screens/library/project_tasks_screen.dart';
|
||||||
import 'screens/chat/chat_screen.dart';
|
import 'screens/chat/chat_screen.dart';
|
||||||
import 'screens/chat/conversations_tab_screen.dart';
|
import 'screens/chat/conversations_tab_screen.dart';
|
||||||
import 'screens/library/library_screen.dart';
|
import 'screens/library/library_screen.dart';
|
||||||
@@ -107,6 +110,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
taskId: int.parse(state.pathParameters['id']!),
|
taskId: int.parse(state.pathParameters['id']!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.projectTasks,
|
||||||
|
builder: (_, state) => ProjectTasksScreen(
|
||||||
|
projectId: int.parse(state.pathParameters['id']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.chat,
|
path: Routes.chat,
|
||||||
builder: (_, state) => ChatScreen(
|
builder: (_, state) => ChatScreen(
|
||||||
@@ -152,15 +161,29 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Silent update check on first app load.
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
// Silent update check — only if we haven't already checked this session.
|
||||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||||
if (repoUrl != null && repoUrl.isNotEmpty) {
|
if (repoUrl != null && repoUrl.isNotEmpty) {
|
||||||
ref.read(updateProvider.notifier).check(repoUrl);
|
final status = ref.read(updateProvider).status;
|
||||||
|
if (status == UpdateStatus.idle || status == UpdateStatus.error) {
|
||||||
|
ref.read(updateProvider.notifier).check(repoUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Sync device timezone to backend so briefing and chat use local time.
|
||||||
|
_syncTimezone();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _syncTimezone() async {
|
||||||
|
try {
|
||||||
|
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||||
|
await ref.read(settingsApiProvider).syncTimezone(tzInfo.identifier);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort — failure is non-critical.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int _tabIndex(String location) {
|
int _tabIndex(String location) {
|
||||||
for (var i = 0; i < _tabs.length; i++) {
|
for (var i = 0; i < _tabs.length; i++) {
|
||||||
if (location.startsWith(_tabs[i])) return i;
|
if (location.startsWith(_tabs[i])) return i;
|
||||||
@@ -181,7 +204,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Version ${state.latestVersion} is ready to install.'),
|
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
||||||
if (state.currentVersion != null)
|
if (state.currentVersion != null)
|
||||||
Text(
|
Text(
|
||||||
'Installed: v${state.currentVersion}',
|
'Installed: v${state.currentVersion}',
|
||||||
@@ -201,6 +224,16 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (state.status == UpdateStatus.error &&
|
||||||
|
state.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
state.errorMessage!,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -208,7 +241,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
|||||||
onPressed: () => Navigator.pop(dialogContext),
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
child: const Text('Later'),
|
child: const Text('Later'),
|
||||||
),
|
),
|
||||||
if (!isDownloading)
|
if (!isDownloading && state.downloadUrl != null)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => ref
|
onPressed: () => ref
|
||||||
.read(updateProvider.notifier)
|
.read(updateProvider.notifier)
|
||||||
@@ -356,8 +389,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
if (!mounted) break;
|
if (!mounted) break;
|
||||||
try {
|
try {
|
||||||
final result = await api.capture(text);
|
final result = await api.capture(text);
|
||||||
if (!mounted) break;
|
// Dequeue before the mounted check — SharedPreferences doesn't need
|
||||||
|
// the widget alive, and skipping this would leave a ghost item.
|
||||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||||
|
if (!mounted) break;
|
||||||
switch (result.type) {
|
switch (result.type) {
|
||||||
case 'note':
|
case 'note':
|
||||||
ref.invalidate(notesProvider);
|
ref.invalidate(notesProvider);
|
||||||
@@ -368,9 +403,9 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
|||||||
} on NetworkException {
|
} on NetworkException {
|
||||||
break;
|
break;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
// Server error or unexpected failure — drop from queue to prevent
|
||||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
// ghost items that can never be cleared.
|
||||||
}
|
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ abstract class Routes {
|
|||||||
static const settings = '/settings';
|
static const settings = '/settings';
|
||||||
static const briefing = '/briefing';
|
static const briefing = '/briefing';
|
||||||
static const library = '/library';
|
static const library = '/library';
|
||||||
|
static const projectTasks = '/projects/:id/tasks';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ class _ErrorInterceptor extends Interceptor {
|
|||||||
));
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (err.type == DioExceptionType.receiveTimeout ||
|
||||||
|
err.type == DioExceptionType.sendTimeout) {
|
||||||
|
handler.reject(DioException(
|
||||||
|
requestOptions: err.requestOptions,
|
||||||
|
error: const AppException('Request timed out. The server is taking too long to respond.'),
|
||||||
|
type: err.type,
|
||||||
|
response: err.response,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,4 +59,23 @@ class BriefingApi {
|
|||||||
throw dioToApp(e);
|
throw dioToApp(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST /api/briefing/rss-reactions body: {rss_item_id, reaction: "up"|"down"}
|
||||||
|
Future<void> postRssReaction(int rssItemId, String reaction) async {
|
||||||
|
try {
|
||||||
|
await _dio.post('/api/briefing/rss-reactions',
|
||||||
|
data: {'rss_item_id': rssItemId, 'reaction': reaction});
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/briefing/rss-reactions/{rssItemId}
|
||||||
|
Future<void> deleteRssReaction(int rssItemId) async {
|
||||||
|
try {
|
||||||
|
await _dio.delete('/api/briefing/rss-reactions/$rssItemId');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class QuickCaptureApi {
|
|||||||
final response = await _dio.post(
|
final response = await _dio.post(
|
||||||
'/api/quick-capture',
|
'/api/quick-capture',
|
||||||
data: {'text': text},
|
data: {'text': text},
|
||||||
|
options: Options(receiveTimeout: const Duration(seconds: 120)),
|
||||||
);
|
);
|
||||||
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
class SettingsApi {
|
||||||
|
const SettingsApi(this._dio);
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Future<void> syncTimezone(String ianaTimezone) async {
|
||||||
|
await _dio.put<void>(
|
||||||
|
'/api/settings',
|
||||||
|
data: {'user_timezone': ianaTimezone},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,24 @@ class TasksApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Task>> getByProject(int projectId) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/notes',
|
||||||
|
queryParameters: {
|
||||||
|
'project_id': projectId,
|
||||||
|
'is_task': 'true',
|
||||||
|
'limit': 500,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = response.data as Map<String, dynamic>;
|
||||||
|
final list = data['notes'] as List<dynamic>;
|
||||||
|
return list.map((e) => Task.fromJson(e as Map<String, dynamic>)).toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw dioToApp(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<Task>> getSubTasks(int parentId) async {
|
Future<List<Task>> getSubTasks(int parentId) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(
|
final response = await _dio.get(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Message {
|
|||||||
final String content;
|
final String content;
|
||||||
final String status; // "complete" | "generating"
|
final String status; // "complete" | "generating"
|
||||||
final DateTime? createdAt;
|
final DateTime? createdAt;
|
||||||
|
final Map<String, dynamic>? metadata;
|
||||||
|
|
||||||
const Message({
|
const Message({
|
||||||
this.id,
|
this.id,
|
||||||
@@ -15,6 +16,7 @@ class Message {
|
|||||||
required this.content,
|
required this.content,
|
||||||
this.status = 'complete',
|
this.status = 'complete',
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
|
this.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
||||||
@@ -26,6 +28,7 @@ class Message {
|
|||||||
createdAt: json['created_at'] != null
|
createdAt: json['created_at'] != null
|
||||||
? DateTime.parse(json['created_at'] as String)
|
? DateTime.parse(json['created_at'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Message copyWith({String? content, String? status}) => Message(
|
Message copyWith({String? content, String? status}) => Message(
|
||||||
@@ -35,5 +38,6 @@ class Message {
|
|||||||
content: content ?? this.content,
|
content: content ?? this.content,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class TasksRepository {
|
|||||||
parentId: parentId,
|
parentId: parentId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
||||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||||
|
|
||||||
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
Future<Task> update(int id, Map<String, dynamic> fields) =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../data/api/milestones_api.dart';
|
|||||||
import '../data/api/notes_api.dart';
|
import '../data/api/notes_api.dart';
|
||||||
import '../data/api/projects_api.dart';
|
import '../data/api/projects_api.dart';
|
||||||
import '../data/api/quick_capture_api.dart';
|
import '../data/api/quick_capture_api.dart';
|
||||||
|
import '../data/api/settings_api.dart';
|
||||||
import '../data/api/tasks_api.dart';
|
import '../data/api/tasks_api.dart';
|
||||||
import '../data/repositories/auth_repository.dart';
|
import '../data/repositories/auth_repository.dart';
|
||||||
import '../data/repositories/chat_repository.dart';
|
import '../data/repositories/chat_repository.dart';
|
||||||
@@ -85,3 +86,7 @@ final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
|||||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||||
return BriefingApi(ref.watch(dioProvider));
|
return BriefingApi(ref.watch(dioProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||||
|
return SettingsApi(ref.watch(dioProvider));
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,24 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
|||||||
return ref.read(briefingApiProvider).getToday();
|
return ref.read(briefingApiProvider).getToday();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Silently fetch the latest briefing and patch state without triggering
|
||||||
|
/// AsyncLoading — existing content stays visible while the fetch is in flight.
|
||||||
|
Future<void> silentRefresh() async {
|
||||||
|
final current = state.value;
|
||||||
|
if (current == null) return;
|
||||||
|
try {
|
||||||
|
final fresh = await ref.read(briefingApiProvider).getToday();
|
||||||
|
final curLast = current.messages.isNotEmpty ? current.messages.last : null;
|
||||||
|
final newLast = fresh.messages.isNotEmpty ? fresh.messages.last : null;
|
||||||
|
if (fresh.messages.length != current.messages.length ||
|
||||||
|
newLast?.content != curLast?.content) {
|
||||||
|
state = AsyncData(fresh);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Network hiccup — silently ignore, keep existing content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
/// Trigger a briefing slot (e.g. "compilation") then reload.
|
||||||
Future<void> refresh(String slot) async {
|
Future<void> refresh(String slot) async {
|
||||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
await ref.read(briefingApiProvider).triggerSlot(slot);
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
|
|||||||
final tasksProvider =
|
final tasksProvider =
|
||||||
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
|
AsyncNotifierProvider<TasksNotifier, List<Task>>(TasksNotifier.new);
|
||||||
|
|
||||||
|
final projectTasksProvider =
|
||||||
|
FutureProvider.family<List<Task>, int>((ref, projectId) {
|
||||||
|
return ref.watch(tasksRepositoryProvider).getByProject(projectId);
|
||||||
|
});
|
||||||
|
|
||||||
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||||
@override
|
@override
|
||||||
Future<List<Task>> build() async {
|
Future<List<Task>> build() async {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:open_file/open_file.dart';
|
import 'package:open_file/open_file.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
||||||
|
|
||||||
@@ -102,6 +103,22 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
|
|
||||||
Future<void> downloadAndInstall() async {
|
Future<void> downloadAndInstall() async {
|
||||||
if (state.downloadUrl == null) return;
|
if (state.downloadUrl == null) return;
|
||||||
|
|
||||||
|
// Android 8+ requires explicit per-app "Install unknown apps" approval
|
||||||
|
// beyond the manifest declaration. Check and redirect to Settings if needed.
|
||||||
|
final installPermission = await Permission.requestInstallPackages.status;
|
||||||
|
if (!installPermission.isGranted) {
|
||||||
|
final result = await Permission.requestInstallPackages.request();
|
||||||
|
if (!result.isGranted) {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: UpdateStatus.error,
|
||||||
|
errorMessage:
|
||||||
|
'Grant "Install unknown apps" permission for Fabled in Settings, then try again.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -119,14 +136,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await OpenFile.open(
|
final result = await OpenFile.open(
|
||||||
path,
|
path,
|
||||||
type: 'application/vnd.android.package-archive',
|
type: 'application/vnd.android.package-archive',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Transition to idle — the system installer is now open.
|
if (result.type == ResultType.done) {
|
||||||
// Going back to `available` would re-trigger the update dialog loop.
|
// Installer launched — reset to idle so the dialog closes naturally.
|
||||||
state = const UpdateState();
|
state = const UpdateState();
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
status: UpdateStatus.error,
|
||||||
|
errorMessage: 'Could not open installer: ${result.message}',
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: UpdateStatus.error,
|
status: UpdateStatus.error,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/exceptions.dart';
|
import '../../core/exceptions.dart';
|
||||||
import '../../data/models/message.dart';
|
import '../../data/models/message.dart';
|
||||||
import '../../providers/briefing_provider.dart';
|
import '../../providers/briefing_provider.dart';
|
||||||
import '../../widgets/briefing_digest_card.dart';
|
import '../../providers/api_client_provider.dart';
|
||||||
import '../../widgets/chat_message_bubble.dart';
|
import '../../widgets/chat_message_bubble.dart';
|
||||||
|
import '../../widgets/weather_card.dart';
|
||||||
import 'briefing_history_screen.dart';
|
import 'briefing_history_screen.dart';
|
||||||
|
|
||||||
class BriefingScreen extends ConsumerStatefulWidget {
|
class BriefingScreen extends ConsumerStatefulWidget {
|
||||||
@@ -15,13 +18,40 @@ class BriefingScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
bool _refreshing = false;
|
bool _refreshing = false;
|
||||||
|
// rss_item_id -> 'up' | 'down' | null
|
||||||
|
final Map<int, String?> _reactions = {};
|
||||||
|
|
||||||
|
Timer? _pollTimer;
|
||||||
|
bool _appInForeground = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
_pollTimer = Timer.periodic(const Duration(seconds: 60), (_) => _pollSilently());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_appInForeground = state == AppLifecycleState.resumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pollSilently() {
|
||||||
|
if (!_appInForeground || !mounted) return;
|
||||||
|
final isStreaming = ref.read(isBriefingStreamingProvider);
|
||||||
|
if (isStreaming) return;
|
||||||
|
ref.read(briefingProvider.notifier).silentRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_pollTimer?.cancel();
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -59,6 +89,22 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleReaction(int itemId, String reaction) async {
|
||||||
|
final current = _reactions[itemId];
|
||||||
|
final next = current == reaction ? null : reaction;
|
||||||
|
setState(() => _reactions[itemId] = next);
|
||||||
|
final api = ref.read(briefingApiProvider);
|
||||||
|
try {
|
||||||
|
if (next == null) {
|
||||||
|
await api.deleteRssReaction(itemId);
|
||||||
|
} else {
|
||||||
|
await api.postRssReaction(itemId, reaction);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setState(() => _reactions[itemId] = current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
setState(() => _refreshing = true);
|
setState(() => _refreshing = true);
|
||||||
try {
|
try {
|
||||||
@@ -146,47 +192,51 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (conv) {
|
data: (conv) {
|
||||||
// First assistant message for the digest card (null if none yet)
|
|
||||||
final Message? firstAssistant = conv.messages
|
|
||||||
.where((m) => m.role == MessageRole.assistant)
|
|
||||||
.toList()
|
|
||||||
.firstOrNull;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// Digest card header
|
|
||||||
BriefingDigestCard(
|
|
||||||
message: firstAssistant,
|
|
||||||
onGenerateNow: _refresh,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Divider + label
|
|
||||||
if (conv.messages.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Row(children: [
|
|
||||||
const Expanded(child: Divider()),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
child: Text(
|
|
||||||
'Conversation',
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Expanded(child: Divider()),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Message list
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
padding: const EdgeInsets.symmetric(
|
slivers: [
|
||||||
horizontal: 8, vertical: 8),
|
if (conv.messages.isEmpty)
|
||||||
itemCount: conv.messages.length,
|
SliverFillRemaining(
|
||||||
itemBuilder: (_, i) =>
|
child: Center(
|
||||||
ChatMessageBubble(message: conv.messages[i]),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'No briefing yet today.',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: _refresh,
|
||||||
|
child: const Text('Generate now'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8, vertical: 8),
|
||||||
|
sliver: SliverList.builder(
|
||||||
|
itemCount: conv.messages.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final msg = conv.messages[i];
|
||||||
|
return _BriefingMessageItem(
|
||||||
|
message: msg,
|
||||||
|
reactions: _reactions,
|
||||||
|
onReaction: _handleReaction,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -250,6 +300,118 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a single briefing message with optional WeatherCard above it
|
||||||
|
/// and RSS reaction buttons below it (for assistant messages with metadata).
|
||||||
|
class _BriefingMessageItem extends StatelessWidget {
|
||||||
|
final Message message;
|
||||||
|
final Map<int, String?> reactions;
|
||||||
|
final void Function(int itemId, String reaction) onReaction;
|
||||||
|
|
||||||
|
const _BriefingMessageItem({
|
||||||
|
required this.message,
|
||||||
|
required this.reactions,
|
||||||
|
required this.onReaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final meta = message.metadata;
|
||||||
|
final isAssistant = message.role == MessageRole.assistant;
|
||||||
|
|
||||||
|
// Weather: show card above when metadata.weather key is present (even if null value)
|
||||||
|
final bool hasWeatherKey = isAssistant && meta != null && meta.containsKey('weather');
|
||||||
|
final weatherData = hasWeatherKey ? meta['weather'] as Map<String, dynamic>? : null;
|
||||||
|
|
||||||
|
// RSS reactions
|
||||||
|
final rssItemIds = isAssistant && meta != null
|
||||||
|
? (meta['rss_item_ids'] as List<dynamic>?)?.cast<int>() ?? []
|
||||||
|
: <int>[];
|
||||||
|
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (hasWeatherKey) WeatherCard(weather: weatherData),
|
||||||
|
ChatMessageBubble(message: message),
|
||||||
|
if (rssItemIds.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8, bottom: 4),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: rssItemIds.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final itemId = entry.value;
|
||||||
|
final current = reactions[itemId];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Story ${index + 1}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ReactionButton(
|
||||||
|
emoji: '👍',
|
||||||
|
active: current == 'up',
|
||||||
|
onTap: () => onReaction(itemId, 'up'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_ReactionButton(
|
||||||
|
emoji: '👎',
|
||||||
|
active: current == 'down',
|
||||||
|
onTap: () => onReaction(itemId, 'down'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionButton extends StatelessWidget {
|
||||||
|
final String emoji;
|
||||||
|
final bool active;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ReactionButton({
|
||||||
|
required this.emoji,
|
||||||
|
required this.active,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active
|
||||||
|
? scheme.primary.withValues(alpha: 0.12)
|
||||||
|
: Colors.transparent,
|
||||||
|
border: Border.all(
|
||||||
|
color: active ? scheme.primary : scheme.outlineVariant,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(emoji, style: const TextStyle(fontSize: 14)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _GradientSendButton extends StatelessWidget {
|
class _GradientSendButton extends StatelessWidget {
|
||||||
final VoidCallback? onPressed;
|
final VoidCallback? onPressed;
|
||||||
final bool isStreaming;
|
final bool isStreaming;
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../../core/constants.dart';
|
||||||
|
import '../../data/models/milestone.dart';
|
||||||
|
import '../../data/models/project.dart';
|
||||||
|
import '../../data/models/task.dart';
|
||||||
|
import '../../providers/api_client_provider.dart';
|
||||||
|
import '../../providers/milestones_provider.dart';
|
||||||
|
import '../../providers/projects_provider.dart';
|
||||||
|
import '../../providers/tasks_provider.dart';
|
||||||
|
|
||||||
|
class ProjectTasksScreen extends ConsumerStatefulWidget {
|
||||||
|
final int projectId;
|
||||||
|
const ProjectTasksScreen({super.key, required this.projectId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ProjectTasksScreen> createState() => _ProjectTasksScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProjectTasksScreenState extends ConsumerState<ProjectTasksScreen> {
|
||||||
|
// Local optimistic status overrides — avoids a reload flash on every cycle tap.
|
||||||
|
final Map<int, TaskStatus> _pendingStatus = {};
|
||||||
|
|
||||||
|
TaskStatus _effectiveStatus(Task task) =>
|
||||||
|
_pendingStatus[task.id] ?? task.status;
|
||||||
|
|
||||||
|
TaskStatus _nextStatus(TaskStatus s) => switch (s) {
|
||||||
|
TaskStatus.todo => TaskStatus.inProgress,
|
||||||
|
TaskStatus.inProgress => TaskStatus.done,
|
||||||
|
TaskStatus.done => TaskStatus.todo,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<void> _cycleStatus(Task task) async {
|
||||||
|
final current = _effectiveStatus(task);
|
||||||
|
final next = _nextStatus(current);
|
||||||
|
setState(() => _pendingStatus[task.id] = next);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(tasksRepositoryProvider)
|
||||||
|
.update(task.id, {'status': next.value});
|
||||||
|
// Sync the global tasks list so the library view stays consistent.
|
||||||
|
ref.invalidate(tasksProvider);
|
||||||
|
} catch (_) {
|
||||||
|
// Revert optimistic change on error.
|
||||||
|
setState(() => _pendingStatus.remove(task.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _parseColor(String? hex) {
|
||||||
|
if (hex == null || hex.isEmpty) return const Color(0xFF6366F1);
|
||||||
|
try {
|
||||||
|
return Color(int.parse(hex.replaceFirst('#', '0xFF')));
|
||||||
|
} catch (_) {
|
||||||
|
return const Color(0xFF6366F1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final tasksAsync = ref.watch(projectTasksProvider(widget.projectId));
|
||||||
|
final milestonesAsync = ref.watch(projectMilestonesProvider(widget.projectId));
|
||||||
|
final project = ref.watch(projectsProvider).value
|
||||||
|
?.whereType<Project>()
|
||||||
|
.where((p) => p.id == widget.projectId)
|
||||||
|
.firstOrNull;
|
||||||
|
|
||||||
|
final color = _parseColor(project?.color);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
project?.title ?? 'Project',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
if (project?.description?.isNotEmpty == true)
|
||||||
|
Text(
|
||||||
|
project!.description!,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: tasksAsync.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => Center(child: Text('Error loading tasks: $e')),
|
||||||
|
data: (tasks) => _buildBody(context, tasks, milestonesAsync, color),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(
|
||||||
|
BuildContext context,
|
||||||
|
List<Task> tasks,
|
||||||
|
AsyncValue<List<Milestone>> milestonesAsync,
|
||||||
|
Color color,
|
||||||
|
) {
|
||||||
|
final milestones = (milestonesAsync.value ?? []).toList()
|
||||||
|
..sort((a, b) => a.orderIndex.compareTo(b.orderIndex));
|
||||||
|
|
||||||
|
// Group tasks by milestoneId.
|
||||||
|
final byMilestone = <int?, List<Task>>{};
|
||||||
|
for (final t in tasks) {
|
||||||
|
(byMilestone[t.milestoneId] ??= []).add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
final unassigned = byMilestone[null] ?? [];
|
||||||
|
|
||||||
|
if (tasks.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_box_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'No tasks in this project yet.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
setState(() => _pendingStatus.clear());
|
||||||
|
ref.invalidate(projectTasksProvider(widget.projectId));
|
||||||
|
ref.invalidate(projectMilestonesProvider(widget.projectId));
|
||||||
|
},
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
// Top colour strip.
|
||||||
|
SliverToBoxAdapter(child: Container(height: 4, color: color)),
|
||||||
|
|
||||||
|
// Milestone sections.
|
||||||
|
for (final ms in milestones) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: _MilestoneHeader(
|
||||||
|
milestone: ms,
|
||||||
|
tasks: byMilestone[ms.id] ?? [],
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ((byMilestone[ms.id] ?? []).isNotEmpty)
|
||||||
|
SliverList.builder(
|
||||||
|
itemCount: byMilestone[ms.id]!.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final task = byMilestone[ms.id]![i];
|
||||||
|
return _TaskRow(
|
||||||
|
task: task,
|
||||||
|
effectiveStatus: _effectiveStatus(task),
|
||||||
|
onStatusTap: () => _cycleStatus(task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unassigned tasks.
|
||||||
|
if (unassigned.isNotEmpty) ...[
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||||
|
child: Text(
|
||||||
|
'No milestone',
|
||||||
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList.builder(
|
||||||
|
itemCount: unassigned.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final task = unassigned[i];
|
||||||
|
return _TaskRow(
|
||||||
|
task: task,
|
||||||
|
effectiveStatus: _effectiveStatus(task),
|
||||||
|
onStatusTap: () => _cycleStatus(task),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Milestone section header ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _MilestoneHeader extends StatelessWidget {
|
||||||
|
final Milestone milestone;
|
||||||
|
final List<Task> tasks;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _MilestoneHeader({
|
||||||
|
required this.milestone,
|
||||||
|
required this.tasks,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final done = tasks.where((t) => t.status == TaskStatus.done).length;
|
||||||
|
final total = tasks.length;
|
||||||
|
final pct = total > 0 ? done / total : 0.0;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 20, 16, 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
milestone.title,
|
||||||
|
style: theme.textTheme.titleSmall,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'$done / $total',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (total > 0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: pct,
|
||||||
|
minHeight: 2,
|
||||||
|
color: color,
|
||||||
|
backgroundColor: color.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task row ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _TaskRow extends StatelessWidget {
|
||||||
|
final Task task;
|
||||||
|
final TaskStatus effectiveStatus;
|
||||||
|
final VoidCallback onStatusTap;
|
||||||
|
|
||||||
|
const _TaskRow({
|
||||||
|
required this.task,
|
||||||
|
required this.effectiveStatus,
|
||||||
|
required this.onStatusTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
IconData get _statusIcon => switch (effectiveStatus) {
|
||||||
|
TaskStatus.done => Icons.check_circle,
|
||||||
|
TaskStatus.inProgress => Icons.timelapse,
|
||||||
|
TaskStatus.todo => Icons.radio_button_unchecked,
|
||||||
|
};
|
||||||
|
|
||||||
|
Color _statusColor(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return switch (effectiveStatus) {
|
||||||
|
TaskStatus.done => const Color(0xFF22C55E),
|
||||||
|
TaskStatus.inProgress => cs.primary,
|
||||||
|
TaskStatus.todo => cs.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _priorityColor(BuildContext context) => switch (task.priority) {
|
||||||
|
TaskPriority.high => const Color(0xFFEF4444),
|
||||||
|
TaskPriority.medium => const Color(0xFFF59E0B),
|
||||||
|
_ => Colors.transparent,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context
|
||||||
|
.push(Routes.taskEdit.replaceFirst(':id', '${task.id}')),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 6, 14, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(_statusIcon, color: _statusColor(context)),
|
||||||
|
onPressed: onStatusTap,
|
||||||
|
tooltip: 'Cycle status',
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
task.title.isNotEmpty ? task.title : 'Untitled',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
decoration: effectiveStatus == TaskStatus.done
|
||||||
|
? TextDecoration.lineThrough
|
||||||
|
: null,
|
||||||
|
color: effectiveStatus == TaskStatus.done
|
||||||
|
? theme.colorScheme.onSurfaceVariant
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (task.dueDate != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'Due ${_formatDate(task.dueDate!)}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: task.dueDate!.isBefore(DateTime.now()) &&
|
||||||
|
effectiveStatus != TaskStatus.done
|
||||||
|
? const Color(0xFFEF4444)
|
||||||
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (task.priority == TaskPriority.high ||
|
||||||
|
task.priority == TaskPriority.medium)
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _priorityColor(context),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime dt) {
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
return '${months[dt.month - 1]} ${dt.day}';
|
||||||
|
}
|
||||||
@@ -210,7 +210,8 @@ class ProjectLibraryCard extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {}, // No project detail screen in this app
|
onTap: () => context
|
||||||
|
.push('/projects/${project.id}/tasks'),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WeatherCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic>? weather;
|
||||||
|
|
||||||
|
const WeatherCard({super.key, required this.weather});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
if (weather == null) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Weather data unavailable — will retry at next slot.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final w = weather!;
|
||||||
|
final location = w['location'] as String? ?? '';
|
||||||
|
final currentTemp = w['current_temp'];
|
||||||
|
final condition = w['condition'] as String? ?? '';
|
||||||
|
final todayHigh = w['today_high'];
|
||||||
|
final todayLow = w['today_low'];
|
||||||
|
final yesterdayHigh = w['yesterday_high'];
|
||||||
|
final fetchedAt = w['fetched_at'] as String?;
|
||||||
|
final forecast = (w['forecast'] as List<dynamic>? ?? [])
|
||||||
|
.cast<Map<String, dynamic>>();
|
||||||
|
|
||||||
|
String? tempDelta;
|
||||||
|
if (todayHigh != null && yesterdayHigh != null) {
|
||||||
|
final diff = (todayHigh as num) - (yesterdayHigh as num);
|
||||||
|
if (diff.abs() < 1) {
|
||||||
|
tempDelta = 'Same as yesterday';
|
||||||
|
} else {
|
||||||
|
final dir = diff > 0 ? 'warmer' : 'cooler';
|
||||||
|
tempDelta = '${diff.abs().round()}° $dir than yesterday';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? fetchedLabel;
|
||||||
|
if (fetchedAt != null) {
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(fetchedAt).toLocal();
|
||||||
|
final h = dt.hour % 12 == 0 ? 12 : dt.hour % 12;
|
||||||
|
final m = dt.minute.toString().padLeft(2, '0');
|
||||||
|
final period = dt.hour < 12 ? 'AM' : 'PM';
|
||||||
|
fetchedLabel = '$h:$m $period';
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: scheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header: location + fetched time
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
location,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 14,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (fetchedLabel != null)
|
||||||
|
Text(
|
||||||
|
'as of $fetchedLabel',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Current temp + condition
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$currentTemp°',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
condition,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Today high/low + delta
|
||||||
|
if (todayHigh != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Today: $todayHigh° / $todayLow°'
|
||||||
|
'${tempDelta != null ? ' · $tempDelta' : ''}',
|
||||||
|
style: TextStyle(fontSize: 13, color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// Forecast strip
|
||||||
|
if (forecast.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: forecast.map((day) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 64,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
day['day'] as String? ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
day['condition'] as String? ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${day['high']}° / ${day['low']}°',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: scheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -169,6 +169,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.2"
|
version: "2.1.2"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -299,6 +307,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_timezone:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_timezone
|
||||||
|
sha256: e8d63f50f2806a3a71a08697286a0369e1d8f0902961327810459871c0bb01c2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.2"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -640,6 +656,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
permission_handler:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: permission_handler
|
||||||
|
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.4.0"
|
||||||
|
permission_handler_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_android
|
||||||
|
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.1.0"
|
||||||
|
permission_handler_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_apple
|
||||||
|
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "9.4.7"
|
||||||
|
permission_handler_html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_html
|
||||||
|
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.3+5"
|
||||||
|
permission_handler_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_platform_interface
|
||||||
|
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.3.0"
|
||||||
|
permission_handler_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: permission_handler_windows
|
||||||
|
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.1"
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ dependencies:
|
|||||||
markdown: ^7.2.2
|
markdown: ^7.2.2
|
||||||
package_info_plus: ^9.0.0
|
package_info_plus: ^9.0.0
|
||||||
open_file: ^3.3.2
|
open_file: ^3.3.2
|
||||||
|
permission_handler: ^11.3.1
|
||||||
flutter_inappwebview: ^6.1.5
|
flutter_inappwebview: ^6.1.5
|
||||||
flutter_markdown_plus: ^1.0.7
|
flutter_markdown_plus: ^1.0.7
|
||||||
google_fonts: ^8.0.2
|
google_fonts: ^8.0.2
|
||||||
|
flutter_timezone: ^5.0.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user