Compare commits

...

22 Commits

Author SHA1 Message Date
bvandeusen ba889a38be Merge pull request 'dev → main' (#9) from dev into main
dev → main
2026-03-26 22:08:04 +00:00
bvandeusen 7fce19a37c feat: silent background polling for briefing screen
Timer.periodic every 60s calls silentRefresh() on BriefingNotifier.
silentRefresh() patches AsyncData directly — never triggers AsyncLoading —
so existing content stays on screen while the fetch is in flight.
WidgetsBindingObserver pauses polling when app is backgrounded.
Polling is also skipped while a reply is actively streaming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:43:06 -04:00
bvandeusen 9f524e158d fix: weather card spacing and sizing consistency
- Consistent 8px vertical rhythm (was 6/4/10 mix)
- Larger current temp (36px vs 32px) for better visual weight
- Fixed 64px forecast column width prevents uneven wrapping
- Forecast condition text clipped at 2 lines with ellipsis
- 12px spacing around forecast divider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:36:53 -04:00
bvandeusen 46d0427901 feat: weather card and RSS reactions in briefing screen
- Parse metadata field on Message model (weather, rss_item_ids)
- Add WeatherCard widget: location, current temp, condition, today hi/lo,
  delta from yesterday, scrollable 3-day forecast strip
- BriefingScreen shows WeatherCard above the first assistant message that
  carries weather metadata (mirrors web BriefingView.vue behaviour)
- RSS reaction buttons (👍/👎) shown below assistant messages with
  rss_item_ids; optimistic toggle with rollback on failure
- Add postRssReaction / deleteRssReaction to BriefingApi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:30:26 -04:00
bvandeusen 45294ade31 feat: sync device timezone to backend on startup
On shell mount, reads the IANA timezone from the device via
flutter_timezone and POSTs it to PUT /api/settings as user_timezone.
Mirrors the web app's App.vue behaviour so briefing events and the
chat route use the correct local time on Android.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:24:59 -04:00
bvandeusen 89c31f1904 Merge pull request 'dev' (#8) from dev into main
Reviewed-on: #8
2026-03-16 15:58:46 +00:00
bvandeusen 9f1d2317af fix: request install-packages permission at runtime before APK install
Android 8+ requires canRequestPackageInstalls() to return true even when
REQUEST_INSTALL_PACKAGES is declared in the manifest. Add permission_handler
and check/request Permission.requestInstallPackages before downloading;
redirects user to Settings if not granted and shows a clear error message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:56:24 -04:00
bvandeusen c3d9cc273f fix: quick capture incorrectly treated as offline on LLM timeout
The global receiveTimeout is 30s but quick capture runs LLM inference
that can take much longer. receiveTimeout fired, fell through to the
NetworkException fallback in dioToApp, and the work queue queued it as
an offline item.

- quick_capture_api.dart: override receiveTimeout to 120s for the
  /api/quick-capture request
- api_client.dart: handle receiveTimeout/sendTimeout explicitly in the
  error interceptor, converting them to AppException (not NetworkException)
  so slow responses are never mistaken for being offline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:54:00 -04:00
bvandeusen 63e01389e8 Merge pull request 'dev to main: big fix and improvements' (#6) from dev into main
Reviewed-on: #6
2026-03-12 21:44:43 -04:00
bvandeusen 5ca5856f15 Project task view: tapping a project shows milestone-grouped task list
- ProjectLibraryCard.onTap navigates to /projects/:id/tasks
- New ProjectTasksScreen: milestone sections with coloured dot,
  done/total counter and thin progress bar; unassigned tasks at bottom
- Status cycle icon updates optimistically (pendingStatus map) so
  the list doesn't flash on every tap; syncs global tasksProvider
- New route /projects/:id/tasks registered in app.dart
- TasksApi.getByProject + TasksRepository + projectTasksProvider (family)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:40:14 -04:00
bvandeusen a337c3fda3 Fix update dialog: show errors, handle OpenFile result correctly
- Check OpenResult.type before resetting state — previously a failed
  open_file call (e.g. installer blocked on emulator) silently called
  state = const UpdateState(), nulling latestVersion and downloadUrl,
  causing "Version null" and a non-functional Download button
- Show errorMessage in dialog with error colour so failures are visible
- Guard Download button on downloadUrl != null (no button if URL lost)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 21:01:01 -04:00
bvandeusen fc1c7cade2 chore: rename APK output to Fabled-<buildNumber>.apk
e.g. Fabled-2603122.apk for tag v26.03.12.2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:52:39 -04:00
bvandeusen 0971433c7a refactor: remove briefing digest card, show full message list directly
The summary card duplicated the first assistant message and consumed
screen real estate without benefit on small screens. Replaced with:
- Full message list via ChatMessageBubble from the top
- SliverFillRemaining empty state ("No briefing yet today" + generate button)
  when no messages exist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 19:08:21 -04:00
bvandeusen 844f68d376 fix: update loop, ghost queue item, briefing card scroll trap
- Update dialog no longer re-appears on every shell re-mount; check()
  is skipped if status is already available/upToDate/downloading
- Offline queue dequeue now happens before the mounted check, preventing
  ghost items when the widget disposes mid-drain
- Briefing digest card and messages now share a CustomScrollView so
  expanding the card doesn't trap the user without scroll access

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:23:24 -04:00
bvandeusen cab8d7104f Merge pull request 'changes to PR process and build number implementation' (#5) from dev into main
Reviewed-on: #5
2026-03-12 07:55:52 -04:00
bvandeusen baba5c3462 feat: inject version from release tag at build time
pubspec.yaml now holds a placeholder (0.0.0+0) that is never updated.
CI strips the v* tag to derive --build-name (e.g. 26.03.12) and
--build-number (e.g. 260312) and passes them to flutter build apk.
2026-03-12 07:52:26 -04:00
bvandeusen 97c049e453 fix: guard release signingConfig behind key.properties existence check
signingConfigs.create() was running unconditionally, casting null
properties to String before the buildTypes guard could take effect.
CI has no key.properties so the cast threw. Now falls back to debug
signing when the keystore file is absent.
2026-03-12 07:51:11 -04:00
bvandeusen a19af3388d Merge pull request 'bug fix' (#4) from dev into main
Reviewed-on: #4
2026-03-12 06:48:30 -04:00
bvandeusen bae6597ec2 fix: remove double status-bar gap between capture bar and AppBar
The narrow-layout body Column had no SafeArea, so the child screen's
AppBar was adding the full status bar height on top of the height already
consumed by _QuickCaptureBar's own SafeArea. MediaQuery.removePadding
(removeTop: true) on the Expanded child tells the inner AppBar the top
inset is already handled.
2026-03-12 06:28:34 -04:00
bvandeusen bc48a49de8 feat: upgrade Riverpod 3, go_router 17, google_fonts 8, package_info_plus 9
Patch/minor (via flutter pub upgrade):
- dio 5.9.1 → 5.9.2
- dio_cookie_manager 3.3.0 → 3.4.0
- dio_web_adapter 2.1.1 → 2.1.2
- hooks 1.0.1 → 1.0.2
- native_toolchain_c 0.17.4 → 0.17.5

Major (via flutter pub upgrade --major-versions + code migration):
- flutter_riverpod 2.6.1 → 3.3.1 + riverpod 2.6.1 → 3.2.1
  - StateNotifierProvider + StateNotifier → NotifierProvider + Notifier
  - StateProvider → NotifierProvider with simple Notifier
  - FamilyAsyncNotifier build(arg) → build() with this.arg
  - AsyncValue.valueOrNull → .value (now T?)
- go_router 14.8.1 → 17.1.0
- google_fonts 6.3.3 → 8.0.2
- package_info_plus 8.3.1 → 9.0.0
2026-03-12 06:21:44 -04:00
bvandeusen a687e3637f feat: upgrade Riverpod 3, go_router 17, google_fonts 8, package_info_plus 9
Migrate StateNotifierProvider/StateNotifier → NotifierProvider/Notifier,
StateProvider → NotifierProvider with simple notifier, FamilyAsyncNotifier
removed in Riverpod 3 (replaced with constructor-arg pattern), and rename
.valueOrNull → .value throughout all providers and screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 06:21:17 -04:00
bvandeusen c8becd6afd fix: revert null-aware map entries; disable use_null_aware_elements lint
The ? operator on map entries applies to the key, not the value.
String literal keys can never be null, so ?'key': value was flagged as
invalid_null_aware_operator. Reverted to if (x != null) 'key': x form
and disabled the lint project-wide since it doesn't apply here.
2026-03-12 00:44:04 -04:00
34 changed files with 1360 additions and 181 deletions
+8 -1
View File
@@ -55,7 +55,14 @@ jobs:
run: flutter pub get run: flutter pub get
- name: Build release APK - name: Build release APK
run: flutter build apk --release run: |
# Derive version from the tag (e.g. v26.03.12 → name=26.03.12 number=260312)
TAG="${{ github.ref_name }}"
BUILD_NAME="${TAG#v}"
BUILD_NUMBER=$(echo "$BUILD_NAME" | tr -d '.')
flutter build apk --release \
--build-name="$BUILD_NAME" \
--build-number="$BUILD_NUMBER"
- name: Set artifact name - name: Set artifact name
id: artifact id: artifact
+3
View File
@@ -23,6 +23,9 @@ linter:
rules: rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule # avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# ?'key': value applies ? to the key (never null for string literals);
# the if (x != null) form is correct for nullable-value map entries.
use_null_aware_elements: false
# Additional information about this file can be found at # Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options # https://dart.dev/guides/language/analysis-options
+8 -6
View File
@@ -37,11 +37,13 @@ android {
} }
signingConfigs { signingConfigs {
create("release") { if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String create("release") {
keyPassword = keystoreProperties["keyPassword"] as String keyAlias = keystoreProperties["keyAlias"] as String
storeFile = file(keystoreProperties["storeFile"] as String) keyPassword = keystoreProperties["keyPassword"] as String
storePassword = keystoreProperties["storePassword"] as String storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
} }
} }
@@ -59,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"
} }
} }
} }
+50 -9
View File
@@ -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)
@@ -283,7 +316,13 @@ class _ShellState extends ConsumerState<_Shell> {
body: Column( body: Column(
children: [ children: [
const _QuickCaptureBar(), const _QuickCaptureBar(),
Expanded(child: child), Expanded(
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: child,
),
),
], ],
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
@@ -350,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);
@@ -362,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);
} }
} }
} }
+1
View File
@@ -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';
} }
+10
View File
@@ -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);
} }
} }
+19
View File
@@ -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);
}
}
} }
+2 -2
View File
@@ -38,7 +38,7 @@ class NotesApi {
'title': title, 'title': title,
'body': body, 'body': body,
'tags': tags, 'tags': tags,
?'project_id': projectId, if (projectId != null) 'project_id': projectId,
}); });
return Note.fromJson(response.data as Map<String, dynamic>); return Note.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) { } on DioException catch (e) {
@@ -59,7 +59,7 @@ class NotesApi {
'title': title, 'title': title,
'body': body, 'body': body,
'tags': tags, 'tags': tags,
if (clearProject) 'project_id': null else ?'project_id': projectId, if (clearProject) 'project_id': null else if (projectId != null) 'project_id': projectId,
}); });
return Note.fromJson(response.data as Map<String, dynamic>); return Note.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) { } on DioException catch (e) {
+1 -1
View File
@@ -44,7 +44,7 @@ class ProjectsApi {
if (description != null && description.isNotEmpty) if (description != null && description.isNotEmpty)
'description': description, 'description': description,
if (goal != null && goal.isNotEmpty) 'goal': goal, if (goal != null && goal.isNotEmpty) 'goal': goal,
?'color': color, if (color != null) 'color': color,
}); });
return Project.fromJson(response.data as Map<String, dynamic>); return Project.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) { } on DioException catch (e) {
+1
View File
@@ -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) {
+13
View File
@@ -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},
);
}
}
+20 -2
View File
@@ -43,8 +43,8 @@ class TasksApi {
'status': status.value, 'status': status.value,
'priority': priority.value, 'priority': priority.value,
'due_date': dueDate?.toIso8601String(), 'due_date': dueDate?.toIso8601String(),
?'project_id': projectId, if (projectId != null) 'project_id': projectId,
?'parent_id': parentId, if (parentId != null) 'parent_id': parentId,
}); });
return Task.fromJson(response.data as Map<String, dynamic>); return Task.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) { } on DioException catch (e) {
@@ -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(
+4
View File
@@ -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) =>
+5
View File
@@ -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));
});
+7 -10
View File
@@ -4,18 +4,15 @@ import 'api_client_provider.dart';
enum AuthStatus { unknown, authenticated, unauthenticated } enum AuthStatus { unknown, authenticated, unauthenticated }
final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) { final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
return AuthNotifier(ref);
});
class AuthNotifier extends StateNotifier<AuthStatus> { class AuthNotifier extends Notifier<AuthStatus> {
final Ref _ref; @override
AuthStatus build() => AuthStatus.unknown;
AuthNotifier(this._ref) : super(AuthStatus.unknown);
Future<void> verify() async { Future<void> verify() async {
try { try {
final repo = _ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
final ok = await repo.verify(); final ok = await repo.verify();
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated; state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
} catch (_) { } catch (_) {
@@ -24,14 +21,14 @@ class AuthNotifier extends StateNotifier<AuthStatus> {
} }
Future<void> login(String username, String password) async { Future<void> login(String username, String password) async {
final repo = _ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
await repo.login(username, password); await repo.login(username, password);
state = AuthStatus.authenticated; state = AuthStatus.authenticated;
} }
Future<void> logout() async { Future<void> logout() async {
try { try {
final repo = _ref.read(authRepositoryProvider); final repo = ref.read(authRepositoryProvider);
await repo.logout(); await repo.logout();
} finally { } finally {
state = AuthStatus.unauthenticated; state = AuthStatus.unauthenticated;
+29 -5
View File
@@ -5,7 +5,13 @@ import '../data/models/message.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
/// Drives the loading indicator in BriefingScreen's reply area. /// Drives the loading indicator in BriefingScreen's reply area.
final isBriefingStreamingProvider = StateProvider<bool>((ref) => false); final isBriefingStreamingProvider =
NotifierProvider<_BoolNotifier, bool>(_BoolNotifier.new);
class _BoolNotifier extends Notifier<bool> {
@override
bool build() => false;
}
final briefingProvider = final briefingProvider =
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>( AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
@@ -17,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);
@@ -32,7 +56,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
/// 3. SSE stream (best-effort) /// 3. SSE stream (best-effort)
/// 4. Poll until complete /// 4. Poll until complete
Future<void> sendReply(String content) async { Future<void> sendReply(String content) async {
final conv = state.valueOrNull; final conv = state.value;
if (conv == null) return; if (conv == null) return;
final convId = conv.id; final convId = conv.id;
final chatApi = ref.read(chatApiProvider); final chatApi = ref.read(chatApiProvider);
@@ -65,7 +89,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
try { try {
await for (final chunk in chatApi.streamGeneration(convId)) { await for (final chunk in chatApi.streamGeneration(convId)) {
streamedContent = true; streamedContent = true;
final current = state.valueOrNull; final current = state.value;
if (current == null) break; if (current == null) break;
final msgs = current.messages; final msgs = current.messages;
if (msgs.isEmpty) continue; if (msgs.isEmpty) continue;
@@ -88,7 +112,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
final hasContent = fresh.any( final hasContent = fresh.any(
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty, (m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
); );
final current = state.valueOrNull; final current = state.value;
if (current != null && (!streamedContent || done || hasContent)) { if (current != null && (!streamedContent || done || hasContent)) {
state = AsyncData(current.copyWith(messages: fresh)); state = AsyncData(current.copyWith(messages: fresh));
} }
@@ -96,7 +120,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
} }
} catch (_) { } catch (_) {
// Clear the generating placeholder so UI doesn't spin forever. // Clear the generating placeholder so UI doesn't spin forever.
final current = state.valueOrNull; final current = state.value;
if (current != null) { if (current != null) {
final msgs = current.messages; final msgs = current.messages;
if (msgs.isNotEmpty && msgs.last.status == 'generating') { if (msgs.isNotEmpty && msgs.last.status == 'generating') {
+10 -6
View File
@@ -4,16 +4,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'settings_provider.dart'; import 'settings_provider.dart';
final captureQueueProvider = final captureQueueProvider =
StateNotifierProvider<CaptureQueueNotifier, List<String>>( NotifierProvider<CaptureQueueNotifier, List<String>>(
(ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)), CaptureQueueNotifier.new,
); );
class CaptureQueueNotifier extends StateNotifier<List<String>> { class CaptureQueueNotifier extends Notifier<List<String>> {
static const _key = 'capture_queue'; static const _key = 'capture_queue';
final SharedPreferences _prefs;
CaptureQueueNotifier(this._prefs) SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
: super(_prefs.getStringList(_key) ?? []);
@override
List<String> build() {
final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getStringList(_key) ?? [];
}
Future<void> enqueue(String text) async { Future<void> enqueue(String text) async {
final updated = [...state, text]; final updated = [...state, text];
+22 -15
View File
@@ -15,20 +15,27 @@ class CaptureResult {
/// The most recent capture result. UI watches this to show snackbars. /// The most recent capture result. UI watches this to show snackbars.
/// Reset to null by the notifier before each new item so listeners always fire. /// Reset to null by the notifier before each new item so listeners always fire.
final captureResultProvider = StateProvider<CaptureResult?>((_) => null); final captureResultProvider =
NotifierProvider<_CaptureResultNotifier, CaptureResult?>(
_CaptureResultNotifier.new);
class _CaptureResultNotifier extends Notifier<CaptureResult?> {
@override
CaptureResult? build() => null;
}
/// In-memory sequential work queue for quick captures. /// In-memory sequential work queue for quick captures.
/// Separate from [captureQueueProvider] (which is the offline persistence queue). /// Separate from [captureQueueProvider] (which is the offline persistence queue).
final captureWorkQueueProvider = final captureWorkQueueProvider =
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>( NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
(ref) => CaptureWorkQueueNotifier(ref), CaptureWorkQueueNotifier.new,
); );
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> { class CaptureWorkQueueNotifier extends Notifier<List<String>> {
final Ref _ref;
bool _running = false; bool _running = false;
CaptureWorkQueueNotifier(this._ref) : super([]); @override
List<String> build() => [];
/// Add text to the queue and start the drain loop if not already running. /// Add text to the queue and start the drain loop if not already running.
void enqueue(String text) { void enqueue(String text) {
@@ -43,9 +50,9 @@ class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
while (state.isNotEmpty) { while (state.isNotEmpty) {
final text = state.first; final text = state.first;
// Signal "no result yet" so the same result value can re-trigger watch. // Signal "no result yet" so the same result value can re-trigger watch.
_ref.read(captureResultProvider.notifier).state = null; ref.read(captureResultProvider.notifier).state = null;
try { try {
final api = _ref.read(quickCaptureApiProvider); final api = ref.read(quickCaptureApiProvider);
final result = await api.capture(text); final result = await api.capture(text);
// Dequeue on success. // Dequeue on success.
@@ -54,32 +61,32 @@ class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
// Invalidate content providers so lists refresh. // Invalidate content providers so lists refresh.
switch (result.type) { switch (result.type) {
case 'note': case 'note':
_ref.invalidate(notesProvider); ref.invalidate(notesProvider);
case 'task': case 'task':
case 'todo': case 'todo':
_ref.invalidate(tasksProvider); ref.invalidate(tasksProvider);
} }
// Publish result for snackbar. // Publish result for snackbar.
final msg = result.message.isNotEmpty final msg = result.message.isNotEmpty
? result.message ? result.message
: '${_typeLabel(result.type)} created: ${result.title}'; : '${_typeLabel(result.type)} created: ${result.title}';
_ref.read(captureResultProvider.notifier).state = CaptureResult(msg); ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
} on NetworkException catch (_) { } on NetworkException catch (_) {
// Persist to offline queue and stop draining — still offline. // Persist to offline queue and stop draining — still offline.
await _ref.read(captureQueueProvider.notifier).enqueue(text); await ref.read(captureQueueProvider.notifier).enqueue(text);
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state = CaptureResult( ref.read(captureResultProvider.notifier).state = CaptureResult(
"You're offline — capture saved and will retry automatically.", "You're offline — capture saved and will retry automatically.",
); );
break; break;
} on AppException catch (e) { } on AppException catch (e) {
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state = ref.read(captureResultProvider.notifier).state =
CaptureResult(e.message, isError: true); CaptureResult(e.message, isError: true);
} catch (_) { } catch (_) {
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
_ref.read(captureResultProvider.notifier).state = ref.read(captureResultProvider.notifier).state =
CaptureResult('Capture failed. Please try again.', isError: true); CaptureResult('Capture failed. Please try again.', isError: true);
} }
} }
+29 -13
View File
@@ -4,8 +4,19 @@ import '../data/models/conversation.dart';
import '../data/models/message.dart'; import '../data/models/message.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
// Separate StateProvider so UI re-builds immediately when streaming starts/stops. // Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
final isStreamingProvider = StateProvider.family<bool, int>((ref, _) => false); final isStreamingProvider =
NotifierProvider.family<_IsStreamingNotifier, bool, int>(
(convId) => _IsStreamingNotifier(convId),
);
class _IsStreamingNotifier extends Notifier<bool> {
// ignore: avoid_unused_constructor_parameters
_IsStreamingNotifier(int convId);
@override
bool build() => false;
}
final conversationsProvider = final conversationsProvider =
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>( AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
@@ -20,14 +31,14 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
Future<Conversation> create(String title) async { Future<Conversation> create(String title) async {
final conv = final conv =
await ref.read(chatRepositoryProvider).createConversation(title); await ref.read(chatRepositoryProvider).createConversation(title);
state = AsyncData([conv, ...state.valueOrNull ?? []]); state = AsyncData([conv, ...state.value ?? []]);
return conv; return conv;
} }
Future<void> delete(int id) async { Future<void> delete(int id) async {
await ref.read(chatRepositoryProvider).deleteConversation(id); await ref.read(chatRepositoryProvider).deleteConversation(id);
state = AsyncData([ state = AsyncData([
for (final c in state.valueOrNull ?? []) for (final c in state.value ?? [])
if (c.id != id) c, if (c.id != id) c,
]); ]);
} }
@@ -35,7 +46,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
// Called after a message is sent to patch the server-generated title // Called after a message is sent to patch the server-generated title
// in-place without triggering a full reload or loading state. // in-place without triggering a full reload or loading state.
void patchConversation(Conversation updated) { void patchConversation(Conversation updated) {
final list = state.valueOrNull; final list = state.value;
if (list == null) return; if (list == null) return;
state = AsyncData([ state = AsyncData([
for (final c in list) for (final c in list)
@@ -44,21 +55,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
} }
} }
final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier, final messagesProvider =
List<Message>, int>(MessagesNotifier.new); AsyncNotifierProvider.family<MessagesNotifier, List<Message>, int>(
(convId) => MessagesNotifier(convId),
);
class MessagesNotifier extends AsyncNotifier<List<Message>> {
final int _convId;
MessagesNotifier(this._convId);
class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
@override @override
Future<List<Message>> build(int arg) async { Future<List<Message>> build() async {
final (_, messages) = final (_, messages) =
await ref.watch(chatRepositoryProvider).getMessages(arg); await ref.watch(chatRepositoryProvider).getMessages(_convId);
return messages; return messages;
} }
Future<void> sendMessage(String content) async { Future<void> sendMessage(String content) async {
final convId = arg; final convId = _convId;
final repo = ref.read(chatRepositoryProvider); final repo = ref.read(chatRepositoryProvider);
final previousMessages = state.valueOrNull ?? []; final previousMessages = state.value ?? [];
// Optimistic UI: show the user message + assistant placeholder immediately. // Optimistic UI: show the user message + assistant placeholder immediately.
final userMsg = Message( final userMsg = Message(
@@ -132,7 +148,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
} catch (_) { } catch (_) {
// Polling failed entirely — clear the generating placeholder so the UI // Polling failed entirely — clear the generating placeholder so the UI
// doesn't spin forever. // doesn't spin forever.
final msgs = state.valueOrNull; final msgs = state.value;
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') { if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
state = AsyncData([ state = AsyncData([
...msgs.sublist(0, msgs.length - 1), ...msgs.sublist(0, msgs.length - 1),
+3 -3
View File
@@ -21,7 +21,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
final note = await ref final note = await ref
.read(notesRepositoryProvider) .read(notesRepositoryProvider)
.create(title, body, tags: tags, projectId: projectId); .create(title, body, tags: tags, projectId: projectId);
state = AsyncData([...state.valueOrNull ?? [], note]); state = AsyncData([...state.value ?? [], note]);
return note; return note;
} }
@@ -42,7 +42,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
clearProject: clearProject, clearProject: clearProject,
); );
state = AsyncData([ state = AsyncData([
for (final n in state.valueOrNull ?? []) for (final n in state.value ?? [])
if (n.id == id) updated else n, if (n.id == id) updated else n,
]); ]);
return updated; return updated;
@@ -51,7 +51,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
Future<void> delete(int id) async { Future<void> delete(int id) async {
await ref.read(notesRepositoryProvider).delete(id); await ref.read(notesRepositoryProvider).delete(id);
state = AsyncData([ state = AsyncData([
for (final n in state.valueOrNull ?? []) for (final n in state.value ?? [])
if (n.id != id) n, if (n.id != id) n,
]); ]);
} }
+3 -3
View File
@@ -25,7 +25,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
goal: goal, goal: goal,
color: color, color: color,
); );
state = AsyncData([...state.valueOrNull ?? [], project]); state = AsyncData([...state.value ?? [], project]);
return project; return project;
} }
@@ -33,7 +33,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
final updated = final updated =
await ref.read(projectsRepositoryProvider).update(id, fields); await ref.read(projectsRepositoryProvider).update(id, fields);
state = AsyncData([ state = AsyncData([
for (final p in state.valueOrNull ?? []) for (final p in state.value ?? [])
if (p.id == id) updated else p, if (p.id == id) updated else p,
]); ]);
return updated; return updated;
@@ -42,7 +42,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
Future<void> delete(int id) async { Future<void> delete(int id) async {
await ref.read(projectsRepositoryProvider).delete(id); await ref.read(projectsRepositoryProvider).delete(id);
state = AsyncData([ state = AsyncData([
for (final p in state.valueOrNull ?? []) for (final p in state.value ?? [])
if (p.id != id) p, if (p.id != id) p,
]); ]);
} }
+28 -29
View File
@@ -15,16 +15,14 @@ final cookiesPathProvider = Provider<String>((ref) {
}); });
final themeModeProvider = final themeModeProvider =
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) { NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);
final prefs = ref.watch(sharedPreferencesProvider);
return ThemeModeNotifier(prefs);
});
class ThemeModeNotifier extends StateNotifier<ThemeMode> { class ThemeModeNotifier extends Notifier<ThemeMode> {
final SharedPreferences _prefs; @override
ThemeMode build() {
ThemeModeNotifier(this._prefs) final prefs = ref.watch(sharedPreferencesProvider);
: super(_fromString(_prefs.getString(_kThemeMode))); return _fromString(prefs.getString(_kThemeMode));
}
static ThemeMode _fromString(String? value) => switch (value) { static ThemeMode _fromString(String? value) => switch (value) {
'light' => ThemeMode.light, 'light' => ThemeMode.light,
@@ -38,48 +36,49 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
ThemeMode.dark => 'dark', ThemeMode.dark => 'dark',
_ => 'system', _ => 'system',
}; };
await _prefs.setString(_kThemeMode, value); await ref.read(sharedPreferencesProvider).setString(_kThemeMode, value);
state = mode; state = mode;
} }
} }
final forgejoRepoUrlProvider = final forgejoRepoUrlProvider =
StateNotifierProvider<ForgejoRepoUrlNotifier, String?>((ref) { NotifierProvider<ForgejoRepoUrlNotifier, String?>(
return ForgejoRepoUrlNotifier(ref.watch(sharedPreferencesProvider)); ForgejoRepoUrlNotifier.new);
});
class ForgejoRepoUrlNotifier extends StateNotifier<String?> { class ForgejoRepoUrlNotifier extends Notifier<String?> {
final SharedPreferences _prefs; @override
ForgejoRepoUrlNotifier(this._prefs) String? build() {
: super(_prefs.getString(_kForgejoRepoUrl) ?? final prefs = ref.watch(sharedPreferencesProvider);
'https://git.fabledsword.com/bvandeusen/FabledApp'); return prefs.getString(_kForgejoRepoUrl) ??
'https://git.fabledsword.com/bvandeusen/FabledApp';
}
Future<void> setUrl(String url) async { Future<void> setUrl(String url) async {
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url; final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kForgejoRepoUrl, clean); await ref.read(sharedPreferencesProvider).setString(_kForgejoRepoUrl, clean);
state = clean; state = clean;
} }
} }
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) { final serverUrlProvider =
final prefs = ref.watch(sharedPreferencesProvider); NotifierProvider<ServerUrlNotifier, String?>(ServerUrlNotifier.new);
return ServerUrlNotifier(prefs);
});
class ServerUrlNotifier extends StateNotifier<String?> { class ServerUrlNotifier extends Notifier<String?> {
final SharedPreferences _prefs; @override
String? build() {
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl)); final prefs = ref.watch(sharedPreferencesProvider);
return prefs.getString(_kServerUrl);
}
Future<void> setUrl(String url) async { Future<void> setUrl(String url) async {
// Strip trailing slash // Strip trailing slash
final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url; final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
await _prefs.setString(_kServerUrl, clean); await ref.read(sharedPreferencesProvider).setString(_kServerUrl, clean);
state = clean; state = clean;
} }
Future<void> clear() async { Future<void> clear() async {
await _prefs.remove(_kServerUrl); await ref.read(sharedPreferencesProvider).remove(_kServerUrl);
state = null; state = null;
} }
} }
+8 -3
View File
@@ -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 {
@@ -28,14 +33,14 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
dueDate: dueDate, dueDate: dueDate,
projectId: projectId, projectId: projectId,
); );
state = AsyncData([...state.valueOrNull ?? [], task]); state = AsyncData([...state.value ?? [], task]);
return task; return task;
} }
Future<Task> updateTask(int id, Map<String, dynamic> fields) async { Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
final updated = await ref.read(tasksRepositoryProvider).update(id, fields); final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
state = AsyncData([ state = AsyncData([
for (final t in state.valueOrNull ?? []) for (final t in state.value ?? [])
if (t.id == id) updated else t, if (t.id == id) updated else t,
]); ]);
return updated; return updated;
@@ -44,7 +49,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
Future<void> delete(int id) async { Future<void> delete(int id) async {
await ref.read(tasksRepositoryProvider).delete(id); await ref.read(tasksRepositoryProvider).delete(id);
state = AsyncData([ state = AsyncData([
for (final t in state.valueOrNull ?? []) for (final t in state.value ?? [])
if (t.id != id) t, if (t.id != id) t,
]); ]);
} }
+27 -4
View File
@@ -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,
+201 -39
View File
@@ -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;
+1 -1
View File
@@ -70,7 +70,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final convTitle = ref final convTitle = ref
.watch(conversationsProvider) .watch(conversationsProvider)
.valueOrNull .value
?.where((c) => c.id == widget.conversationId) ?.where((c) => c.id == widget.conversationId)
.firstOrNull .firstOrNull
?.title; ?.title;
+2 -2
View File
@@ -228,8 +228,8 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
AsyncValue<List<Note>> notesAsync, AsyncValue<List<Note>> notesAsync,
AsyncValue<List<Task>> tasksAsync, AsyncValue<List<Task>> tasksAsync,
) { ) {
final notes = notesAsync.valueOrNull ?? []; final notes = notesAsync.value ?? [];
final tasks = tasksAsync.valueOrNull ?? []; final tasks = tasksAsync.value ?? [];
// Merge and sort by updatedAt desc // Merge and sort by updatedAt desc
final items = <(DateTime, Widget)>[]; final items = <(DateTime, Widget)>[];
@@ -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}';
}
+1 -1
View File
@@ -16,7 +16,7 @@ class NoteDetailScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final noteAsync = ref.watch(noteDetailProvider(noteId)); final noteAsync = ref.watch(noteDetailProvider(noteId));
final allNotes = ref.watch(notesProvider).valueOrNull ?? []; final allNotes = ref.watch(notesProvider).value ?? [];
void navigateByTitle(String title) { void navigateByTitle(String title) {
final matches = allNotes.where( final matches = allNotes.where(
+2 -1
View File
@@ -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: [
+185
View File
@@ -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(),
),
),
],
],
),
);
}
}
+268 -20
View File
@@ -1,6 +1,22 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d"
url: "https://pub.dev"
source: hosted
version: "93.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b
url: "https://pub.dev"
source: hosted
version: "10.0.1"
archive: archive:
dependency: transitive dependency: transitive
description: description:
@@ -49,6 +65,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.4" version: "2.0.4"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
cli_util: cli_util:
dependency: transitive dependency: transitive
description: description:
@@ -81,6 +105,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cookie_jar: cookie_jar:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -89,6 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.9" version: "4.0.9"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -109,26 +149,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.9.1" version: "5.9.2"
dio_cookie_manager: dio_cookie_manager:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio_cookie_manager name: dio_cookie_manager
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0 sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.3.0" version: "3.4.0"
dio_web_adapter: dio_web_adapter:
dependency: transitive dependency: transitive
description: description:
name: dio_web_adapter name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" 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:
@@ -250,20 +298,36 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_riverpod name: flutter_riverpod
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "3.3.1"
flutter_test: flutter_test:
dependency: "direct dev" dependency: "direct dev"
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
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@@ -276,26 +340,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.8.1" version: "17.1.0"
google_fonts: google_fonts:
dependency: "direct main" dependency: "direct main"
description: description:
name: google_fonts name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "8.0.2"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
name: hooks name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.2"
http: http:
dependency: transitive dependency: transitive
description: description:
@@ -304,6 +368,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser: http_parser:
dependency: transitive dependency: transitive
description: description:
@@ -320,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.8.0" version: "4.8.0"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -412,10 +492,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: native_toolchain_c name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.4" version: "0.17.5"
node_preamble:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objective_c: objective_c:
dependency: transitive dependency: transitive
description: description:
@@ -488,14 +576,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.3" version: "0.0.3"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus: package_info_plus:
dependency: "direct main" dependency: "direct main"
description: description:
name: package_info_plus name: package_info_plus
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.3.1" version: "9.0.0"
package_info_plus_platform_interface: package_info_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -560,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:
@@ -584,6 +728,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix: posix:
dependency: transitive dependency: transitive
description: description:
@@ -604,10 +756,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" version: "3.2.1"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -664,11 +816,59 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span: source_span:
dependency: transitive dependency: transitive
description: description:
@@ -717,6 +917,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.30.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
@@ -725,6 +933,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.16"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -757,6 +973,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web: web:
dependency: transitive dependency: transitive
description: description:
@@ -765,6 +989,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32: win32:
dependency: transitive dependency: transitive
description: description:
+7 -5
View File
@@ -2,7 +2,7 @@ name: fabled_app
description: "FabledAssistant mobile client for Android." description: "FabledAssistant mobile client for Android."
publish_to: 'none' publish_to: 'none'
version: 26.03.12+1 version: 0.0.0+0 # overridden at build time via --build-name / --build-number
environment: environment:
sdk: ^3.11.0 sdk: ^3.11.0
@@ -12,19 +12,21 @@ dependencies:
sdk: flutter sdk: flutter
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_riverpod: ^2.5.1 flutter_riverpod: ^3.3.1
go_router: ^14.2.0 go_router: ^17.1.0
dio: ^5.6.0 dio: ^5.6.0
cookie_jar: ^4.0.8 cookie_jar: ^4.0.8
dio_cookie_manager: ^3.1.1 dio_cookie_manager: ^3.1.1
path_provider: ^2.1.4 path_provider: ^2.1.4
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
markdown: ^7.2.2 markdown: ^7.2.2
package_info_plus: ^8.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: ^6.2.1 google_fonts: ^8.0.2
flutter_timezone: ^5.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: