Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24cef2e78c | |||
| 399da397da | |||
| 2bdd663719 | |||
| ba889a38be | |||
| 7fce19a37c | |||
| 9f524e158d | |||
| 46d0427901 | |||
| 45294ade31 | |||
| 89c31f1904 | |||
| 9f1d2317af | |||
| c3d9cc273f | |||
| 63e01389e8 | |||
| 5ca5856f15 | |||
| a337c3fda3 | |||
| fc1c7cade2 | |||
| 0971433c7a | |||
| 844f68d376 | |||
| cab8d7104f | |||
| baba5c3462 | |||
| 97c049e453 | |||
| a19af3388d | |||
| bae6597ec2 | |||
| bc48a49de8 | |||
| a687e3637f | |||
| c8becd6afd | |||
| e63370bf0a | |||
| f39b8ddb30 | |||
| def7519feb | |||
| e86d1a59af | |||
| c8f3861eb5 | |||
| a2e734c498 | |||
| 3a336ddf88 | |||
| 6af23fc853 |
+47
-45
@@ -1,12 +1,14 @@
|
||||
# CI runs first; build only proceeds if analyze and test pass.
|
||||
# CI runs only on release tags.
|
||||
#
|
||||
# Push to dev: analyze + test → build APK → upload artifact (fabledapp-dev-<sha>)
|
||||
# Push to main: analyze + test only (no build — wait for release tag)
|
||||
# Tag v26.03.11: analyze + test → build APK → upload artifact + create Forgejo Release
|
||||
# Pull request: analyze + test only
|
||||
# Tag v*: analyze + test → build APK → attach to Forgejo Release
|
||||
#
|
||||
# To cut a release:
|
||||
# git tag v26.03.11 && git push origin v26.03.11
|
||||
# Create a release via the Forgejo UI on main with a v* tag name.
|
||||
# The tag push triggers this workflow; the build job attaches the APK.
|
||||
#
|
||||
# Note: JavaScript actions (actions/checkout, actions/upload-artifact) cannot run
|
||||
# inside the Flutter container because it has no Node.js. All steps use shell
|
||||
# commands directly instead.
|
||||
#
|
||||
# Required secrets (repo → Settings → Secrets → Actions):
|
||||
# RELEASE_TOKEN — Forgejo PAT with write:repository scope
|
||||
@@ -14,25 +16,7 @@ name: CI & Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
tags: ["v*"]
|
||||
paths:
|
||||
- "lib/**"
|
||||
- "test/**"
|
||||
- "pubspec.yaml"
|
||||
- "pubspec.lock"
|
||||
- "analysis_options.yaml"
|
||||
- "android/**"
|
||||
- "assets/**"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "lib/**"
|
||||
- "test/**"
|
||||
- "pubspec.yaml"
|
||||
- "pubspec.lock"
|
||||
- "analysis_options.yaml"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -41,7 +25,10 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:stable
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
|
||||
git checkout "$GITHUB_SHA"
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
@@ -55,42 +42,57 @@ jobs:
|
||||
build:
|
||||
name: Build release APK
|
||||
needs: [analyze]
|
||||
# Build on dev branch pushes and version tag pushes only.
|
||||
# main branch pushes run CI for safety but do not build —
|
||||
# the release tag (v*) is the sole trigger for a production APK.
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
|
||||
runs-on: py3.12-node22
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/flutter:stable
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
|
||||
git checkout "$GITHUB_SHA"
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Set up release signing
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }}
|
||||
STORE_PASSWORD: ${{ secrets.RELEASE_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
|
||||
run: |
|
||||
echo "$KEYSTORE_B64" | base64 -d > android/app/release.jks
|
||||
printf 'storePassword=%s\nkeyPassword=%s\nkeyAlias=%s\nstoreFile=release.jks\n' \
|
||||
"$STORE_PASSWORD" "$STORE_PASSWORD" "$KEY_ALIAS" > android/key.properties
|
||||
|
||||
- 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
|
||||
id: artifact
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
|
||||
echo "name=fabledapp-dev-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "name=fabledapp-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "name=fabledapp-${{ github.ref_name }}-${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
path: build/app/outputs/flutter-apk/app-release.apk
|
||||
retention-days: 30
|
||||
- name: Upload artifact to Forgejo
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
APK: build/app/outputs/flutter-apk/app-release.apk
|
||||
API: https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledApp
|
||||
ARTIFACT_NAME: ${{ steps.artifact.outputs.name }}
|
||||
run: |
|
||||
# Upload the APK as a workflow artifact via the Forgejo API.
|
||||
curl -s -X POST "$API/actions/artifacts" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-F "name=$ARTIFACT_NAME" \
|
||||
-F "file=@$APK" || echo "Artifact upload skipped (API may not support this endpoint)."
|
||||
|
||||
- name: Publish Forgejo release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
|
||||
@@ -23,6 +23,9 @@ linter:
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` 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
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
|
||||
@@ -10,3 +10,4 @@ GeneratedPluginRegistrant.java
|
||||
# Signing secrets — never commit these
|
||||
key.properties
|
||||
fabled-release-key.jks
|
||||
app/release.jks
|
||||
|
||||
@@ -37,11 +37,13 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ android {
|
||||
val variant = this
|
||||
outputs.all {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-10
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
import 'core/constants.dart';
|
||||
import 'core/exceptions.dart';
|
||||
import 'core/theme.dart';
|
||||
@@ -15,6 +17,7 @@ import 'providers/update_provider.dart';
|
||||
import 'providers/tasks_provider.dart';
|
||||
import 'screens/auth/login_screen.dart';
|
||||
import 'screens/briefing/briefing_screen.dart';
|
||||
import 'screens/library/project_tasks_screen.dart';
|
||||
import 'screens/chat/chat_screen.dart';
|
||||
import 'screens/chat/conversations_tab_screen.dart';
|
||||
import 'screens/library/library_screen.dart';
|
||||
@@ -107,6 +110,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
taskId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.projectTasks,
|
||||
builder: (_, state) => ProjectTasksScreen(
|
||||
projectId: int.parse(state.pathParameters['id']!),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: Routes.chat,
|
||||
builder: (_, state) => ChatScreen(
|
||||
@@ -152,15 +161,29 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Silent update check on first app load.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Silent update check — only if we haven't already checked this session.
|
||||
final repoUrl = ref.read(forgejoRepoUrlProvider);
|
||||
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) {
|
||||
for (var i = 0; i < _tabs.length; i++) {
|
||||
if (location.startsWith(_tabs[i])) return i;
|
||||
@@ -181,7 +204,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Version ${state.latestVersion} is ready to install.'),
|
||||
Text('Version ${state.latestVersion ?? '?'} is ready to install.'),
|
||||
if (state.currentVersion != null)
|
||||
Text(
|
||||
'Installed: v${state.currentVersion}',
|
||||
@@ -201,6 +224,16 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
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: [
|
||||
@@ -208,7 +241,7 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('Later'),
|
||||
),
|
||||
if (!isDownloading)
|
||||
if (!isDownloading && state.downloadUrl != null)
|
||||
FilledButton(
|
||||
onPressed: () => ref
|
||||
.read(updateProvider.notifier)
|
||||
@@ -283,7 +316,13 @@ class _ShellState extends ConsumerState<_Shell> {
|
||||
body: Column(
|
||||
children: [
|
||||
const _QuickCaptureBar(),
|
||||
Expanded(child: child),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
@@ -350,8 +389,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
if (!mounted) break;
|
||||
try {
|
||||
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);
|
||||
if (!mounted) break;
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
ref.invalidate(notesProvider);
|
||||
@@ -362,16 +403,16 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
|
||||
} on NetworkException {
|
||||
break;
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
// Server error or unexpected failure — drop from queue to prevent
|
||||
// ghost items that can never be cleared.
|
||||
await ref.read(captureQueueProvider.notifier).dequeue(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _hintForLocation(String location) {
|
||||
if (location.startsWith(Routes.library) &&
|
||||
location.contains('tasks')) return 'Add a task…';
|
||||
location.contains('tasks')) { return 'Add a task…'; }
|
||||
if (location.startsWith(Routes.conversations)) return 'Ask Fabled…';
|
||||
return 'Capture a note…';
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ abstract class Routes {
|
||||
static const settings = '/settings';
|
||||
static const briefing = '/briefing';
|
||||
static const library = '/library';
|
||||
static const projectTasks = '/projects/:id/tasks';
|
||||
}
|
||||
|
||||
@@ -44,6 +44,16 @@ class _ErrorInterceptor extends Interceptor {
|
||||
));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ class BriefingApi {
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/briefing/conversations/<id>/messages
|
||||
/// GET /api/briefing/conversations/`<id>`/messages
|
||||
Future<List<Message>> getMessages(int convId) async {
|
||||
try {
|
||||
final response =
|
||||
@@ -59,4 +59,23 @@ class BriefingApi {
|
||||
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(
|
||||
'/api/quick-capture',
|
||||
data: {'text': text},
|
||||
options: Options(receiveTimeout: const Duration(seconds: 120)),
|
||||
);
|
||||
return CaptureResult.fromJson(response.data as Map<String, dynamic>);
|
||||
} 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 {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
|
||||
@@ -7,6 +7,7 @@ class Message {
|
||||
final String content;
|
||||
final String status; // "complete" | "generating"
|
||||
final DateTime? createdAt;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const Message({
|
||||
this.id,
|
||||
@@ -15,6 +16,7 @@ class Message {
|
||||
required this.content,
|
||||
this.status = 'complete',
|
||||
this.createdAt,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
||||
@@ -26,6 +28,7 @@ class Message {
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: null,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
Message copyWith({String? content, String? status}) => Message(
|
||||
@@ -35,5 +38,6 @@ class Message {
|
||||
content: content ?? this.content,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class TasksRepository {
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
Future<List<Task>> getByProject(int projectId) => _api.getByProject(projectId);
|
||||
Future<List<Task>> getSubTasks(int parentId) => _api.getSubTasks(parentId);
|
||||
|
||||
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/projects_api.dart';
|
||||
import '../data/api/quick_capture_api.dart';
|
||||
import '../data/api/settings_api.dart';
|
||||
import '../data/api/tasks_api.dart';
|
||||
import '../data/repositories/auth_repository.dart';
|
||||
import '../data/repositories/chat_repository.dart';
|
||||
@@ -85,3 +86,7 @@ final milestonesRepositoryProvider = Provider<MilestonesRepository>((ref) {
|
||||
final briefingApiProvider = Provider<BriefingApi>((ref) {
|
||||
return BriefingApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
final settingsApiProvider = Provider<SettingsApi>((ref) {
|
||||
return SettingsApi(ref.watch(dioProvider));
|
||||
});
|
||||
|
||||
@@ -4,18 +4,15 @@ import 'api_client_provider.dart';
|
||||
|
||||
enum AuthStatus { unknown, authenticated, unauthenticated }
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthStatus>((ref) {
|
||||
return AuthNotifier(ref);
|
||||
});
|
||||
final authProvider = NotifierProvider<AuthNotifier, AuthStatus>(AuthNotifier.new);
|
||||
|
||||
class AuthNotifier extends StateNotifier<AuthStatus> {
|
||||
final Ref _ref;
|
||||
|
||||
AuthNotifier(this._ref) : super(AuthStatus.unknown);
|
||||
class AuthNotifier extends Notifier<AuthStatus> {
|
||||
@override
|
||||
AuthStatus build() => AuthStatus.unknown;
|
||||
|
||||
Future<void> verify() async {
|
||||
try {
|
||||
final repo = _ref.read(authRepositoryProvider);
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
final ok = await repo.verify();
|
||||
state = ok ? AuthStatus.authenticated : AuthStatus.unauthenticated;
|
||||
} catch (_) {
|
||||
@@ -24,14 +21,14 @@ class AuthNotifier extends StateNotifier<AuthStatus> {
|
||||
}
|
||||
|
||||
Future<void> login(String username, String password) async {
|
||||
final repo = _ref.read(authRepositoryProvider);
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.login(username, password);
|
||||
state = AuthStatus.authenticated;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
final repo = _ref.read(authRepositoryProvider);
|
||||
final repo = ref.read(authRepositoryProvider);
|
||||
await repo.logout();
|
||||
} finally {
|
||||
state = AuthStatus.unauthenticated;
|
||||
|
||||
@@ -5,7 +5,13 @@ import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
/// 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 =
|
||||
AsyncNotifierProvider<BriefingNotifier, BriefingConversation>(
|
||||
@@ -17,6 +23,24 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
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.
|
||||
Future<void> refresh(String slot) async {
|
||||
await ref.read(briefingApiProvider).triggerSlot(slot);
|
||||
@@ -32,7 +56,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
/// 3. SSE stream (best-effort)
|
||||
/// 4. Poll until complete
|
||||
Future<void> sendReply(String content) async {
|
||||
final conv = state.valueOrNull;
|
||||
final conv = state.value;
|
||||
if (conv == null) return;
|
||||
final convId = conv.id;
|
||||
final chatApi = ref.read(chatApiProvider);
|
||||
@@ -65,7 +89,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
try {
|
||||
await for (final chunk in chatApi.streamGeneration(convId)) {
|
||||
streamedContent = true;
|
||||
final current = state.valueOrNull;
|
||||
final current = state.value;
|
||||
if (current == null) break;
|
||||
final msgs = current.messages;
|
||||
if (msgs.isEmpty) continue;
|
||||
@@ -88,7 +112,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
final hasContent = fresh.any(
|
||||
(m) => m.role == MessageRole.assistant && m.content.isNotEmpty,
|
||||
);
|
||||
final current = state.valueOrNull;
|
||||
final current = state.value;
|
||||
if (current != null && (!streamedContent || done || hasContent)) {
|
||||
state = AsyncData(current.copyWith(messages: fresh));
|
||||
}
|
||||
@@ -96,7 +120,7 @@ class BriefingNotifier extends AsyncNotifier<BriefingConversation> {
|
||||
}
|
||||
} catch (_) {
|
||||
// Clear the generating placeholder so UI doesn't spin forever.
|
||||
final current = state.valueOrNull;
|
||||
final current = state.value;
|
||||
if (current != null) {
|
||||
final msgs = current.messages;
|
||||
if (msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
|
||||
@@ -4,16 +4,20 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'settings_provider.dart';
|
||||
|
||||
final captureQueueProvider =
|
||||
StateNotifierProvider<CaptureQueueNotifier, List<String>>(
|
||||
(ref) => CaptureQueueNotifier(ref.watch(sharedPreferencesProvider)),
|
||||
NotifierProvider<CaptureQueueNotifier, List<String>>(
|
||||
CaptureQueueNotifier.new,
|
||||
);
|
||||
|
||||
class CaptureQueueNotifier extends StateNotifier<List<String>> {
|
||||
class CaptureQueueNotifier extends Notifier<List<String>> {
|
||||
static const _key = 'capture_queue';
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
CaptureQueueNotifier(this._prefs)
|
||||
: super(_prefs.getStringList(_key) ?? []);
|
||||
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
|
||||
|
||||
@override
|
||||
List<String> build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getStringList(_key) ?? [];
|
||||
}
|
||||
|
||||
Future<void> enqueue(String text) async {
|
||||
final updated = [...state, text];
|
||||
|
||||
@@ -15,20 +15,27 @@ class CaptureResult {
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
||||
final captureWorkQueueProvider =
|
||||
StateNotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
(ref) => CaptureWorkQueueNotifier(ref),
|
||||
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||
CaptureWorkQueueNotifier.new,
|
||||
);
|
||||
|
||||
class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
|
||||
final Ref _ref;
|
||||
class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
||||
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.
|
||||
void enqueue(String text) {
|
||||
@@ -43,9 +50,9 @@ class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
|
||||
while (state.isNotEmpty) {
|
||||
final text = state.first;
|
||||
// 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 {
|
||||
final api = _ref.read(quickCaptureApiProvider);
|
||||
final api = ref.read(quickCaptureApiProvider);
|
||||
final result = await api.capture(text);
|
||||
|
||||
// Dequeue on success.
|
||||
@@ -54,32 +61,32 @@ class CaptureWorkQueueNotifier extends StateNotifier<List<String>> {
|
||||
// Invalidate content providers so lists refresh.
|
||||
switch (result.type) {
|
||||
case 'note':
|
||||
_ref.invalidate(notesProvider);
|
||||
ref.invalidate(notesProvider);
|
||||
case 'task':
|
||||
case 'todo':
|
||||
_ref.invalidate(tasksProvider);
|
||||
ref.invalidate(tasksProvider);
|
||||
}
|
||||
|
||||
// Publish result for snackbar.
|
||||
final msg = result.message.isNotEmpty
|
||||
? result.message
|
||||
: '${_typeLabel(result.type)} created: ${result.title}';
|
||||
_ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
|
||||
ref.read(captureResultProvider.notifier).state = CaptureResult(msg);
|
||||
} on NetworkException catch (_) {
|
||||
// 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) : [];
|
||||
_ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||
ref.read(captureResultProvider.notifier).state = CaptureResult(
|
||||
"You're offline — capture saved and will retry automatically.",
|
||||
);
|
||||
break;
|
||||
} on AppException catch (e) {
|
||||
state = state.length > 1 ? state.sublist(1) : [];
|
||||
_ref.read(captureResultProvider.notifier).state =
|
||||
ref.read(captureResultProvider.notifier).state =
|
||||
CaptureResult(e.message, isError: true);
|
||||
} catch (_) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,19 @@ import '../data/models/conversation.dart';
|
||||
import '../data/models/message.dart';
|
||||
import 'api_client_provider.dart';
|
||||
|
||||
// Separate StateProvider so UI re-builds immediately when streaming starts/stops.
|
||||
final isStreamingProvider = StateProvider.family<bool, int>((ref, _) => false);
|
||||
// Separate NotifierProvider.family so UI re-builds immediately when streaming starts/stops.
|
||||
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 =
|
||||
AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(
|
||||
@@ -20,14 +31,14 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
Future<Conversation> create(String title) async {
|
||||
final conv =
|
||||
await ref.read(chatRepositoryProvider).createConversation(title);
|
||||
state = AsyncData([conv, ...state.valueOrNull ?? []]);
|
||||
state = AsyncData([conv, ...state.value ?? []]);
|
||||
return conv;
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(chatRepositoryProvider).deleteConversation(id);
|
||||
state = AsyncData([
|
||||
for (final c in state.valueOrNull ?? [])
|
||||
for (final c in state.value ?? [])
|
||||
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
|
||||
// in-place without triggering a full reload or loading state.
|
||||
void patchConversation(Conversation updated) {
|
||||
final list = state.valueOrNull;
|
||||
final list = state.value;
|
||||
if (list == null) return;
|
||||
state = AsyncData([
|
||||
for (final c in list)
|
||||
@@ -44,21 +55,26 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
|
||||
}
|
||||
}
|
||||
|
||||
final messagesProvider = AsyncNotifierProvider.family<MessagesNotifier,
|
||||
List<Message>, int>(MessagesNotifier.new);
|
||||
final messagesProvider =
|
||||
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
|
||||
Future<List<Message>> build(int arg) async {
|
||||
Future<List<Message>> build() async {
|
||||
final (_, messages) =
|
||||
await ref.watch(chatRepositoryProvider).getMessages(arg);
|
||||
await ref.watch(chatRepositoryProvider).getMessages(_convId);
|
||||
return messages;
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final convId = arg;
|
||||
final convId = _convId;
|
||||
final repo = ref.read(chatRepositoryProvider);
|
||||
final previousMessages = state.valueOrNull ?? [];
|
||||
final previousMessages = state.value ?? [];
|
||||
|
||||
// Optimistic UI: show the user message + assistant placeholder immediately.
|
||||
final userMsg = Message(
|
||||
@@ -132,7 +148,7 @@ class MessagesNotifier extends FamilyAsyncNotifier<List<Message>, int> {
|
||||
} catch (_) {
|
||||
// Polling failed entirely — clear the generating placeholder so the UI
|
||||
// doesn't spin forever.
|
||||
final msgs = state.valueOrNull;
|
||||
final msgs = state.value;
|
||||
if (msgs != null && msgs.isNotEmpty && msgs.last.status == 'generating') {
|
||||
state = AsyncData([
|
||||
...msgs.sublist(0, msgs.length - 1),
|
||||
|
||||
@@ -21,7 +21,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
final note = await ref
|
||||
.read(notesRepositoryProvider)
|
||||
.create(title, body, tags: tags, projectId: projectId);
|
||||
state = AsyncData([...state.valueOrNull ?? [], note]);
|
||||
state = AsyncData([...state.value ?? [], note]);
|
||||
return note;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
clearProject: clearProject,
|
||||
);
|
||||
state = AsyncData([
|
||||
for (final n in state.valueOrNull ?? [])
|
||||
for (final n in state.value ?? [])
|
||||
if (n.id == id) updated else n,
|
||||
]);
|
||||
return updated;
|
||||
@@ -51,7 +51,7 @@ class NotesNotifier extends AsyncNotifier<List<Note>> {
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(notesRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final n in state.valueOrNull ?? [])
|
||||
for (final n in state.value ?? [])
|
||||
if (n.id != id) n,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
goal: goal,
|
||||
color: color,
|
||||
);
|
||||
state = AsyncData([...state.valueOrNull ?? [], project]);
|
||||
state = AsyncData([...state.value ?? [], project]);
|
||||
return project;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
final updated =
|
||||
await ref.read(projectsRepositoryProvider).update(id, fields);
|
||||
state = AsyncData([
|
||||
for (final p in state.valueOrNull ?? [])
|
||||
for (final p in state.value ?? [])
|
||||
if (p.id == id) updated else p,
|
||||
]);
|
||||
return updated;
|
||||
@@ -42,7 +42,7 @@ class ProjectsNotifier extends AsyncNotifier<List<Project>> {
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(projectsRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final p in state.valueOrNull ?? [])
|
||||
for (final p in state.value ?? [])
|
||||
if (p.id != id) p,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -15,16 +15,14 @@ final cookiesPathProvider = Provider<String>((ref) {
|
||||
});
|
||||
|
||||
final themeModeProvider =
|
||||
StateNotifierProvider<ThemeModeNotifier, ThemeMode>((ref) {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return ThemeModeNotifier(prefs);
|
||||
});
|
||||
NotifierProvider<ThemeModeNotifier, ThemeMode>(ThemeModeNotifier.new);
|
||||
|
||||
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ThemeModeNotifier(this._prefs)
|
||||
: super(_fromString(_prefs.getString(_kThemeMode)));
|
||||
class ThemeModeNotifier extends Notifier<ThemeMode> {
|
||||
@override
|
||||
ThemeMode build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return _fromString(prefs.getString(_kThemeMode));
|
||||
}
|
||||
|
||||
static ThemeMode _fromString(String? value) => switch (value) {
|
||||
'light' => ThemeMode.light,
|
||||
@@ -38,48 +36,49 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
|
||||
ThemeMode.dark => 'dark',
|
||||
_ => 'system',
|
||||
};
|
||||
await _prefs.setString(_kThemeMode, value);
|
||||
await ref.read(sharedPreferencesProvider).setString(_kThemeMode, value);
|
||||
state = mode;
|
||||
}
|
||||
}
|
||||
|
||||
final forgejoRepoUrlProvider =
|
||||
StateNotifierProvider<ForgejoRepoUrlNotifier, String?>((ref) {
|
||||
return ForgejoRepoUrlNotifier(ref.watch(sharedPreferencesProvider));
|
||||
});
|
||||
NotifierProvider<ForgejoRepoUrlNotifier, String?>(
|
||||
ForgejoRepoUrlNotifier.new);
|
||||
|
||||
class ForgejoRepoUrlNotifier extends StateNotifier<String?> {
|
||||
final SharedPreferences _prefs;
|
||||
ForgejoRepoUrlNotifier(this._prefs)
|
||||
: super(_prefs.getString(_kForgejoRepoUrl) ??
|
||||
'https://git.fabledsword.com/bvandeusen/FabledApp');
|
||||
class ForgejoRepoUrlNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getString(_kForgejoRepoUrl) ??
|
||||
'https://git.fabledsword.com/bvandeusen/FabledApp';
|
||||
}
|
||||
|
||||
Future<void> setUrl(String url) async {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
final serverUrlProvider = StateNotifierProvider<ServerUrlNotifier, String?>((ref) {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return ServerUrlNotifier(prefs);
|
||||
});
|
||||
final serverUrlProvider =
|
||||
NotifierProvider<ServerUrlNotifier, String?>(ServerUrlNotifier.new);
|
||||
|
||||
class ServerUrlNotifier extends StateNotifier<String?> {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ServerUrlNotifier(this._prefs) : super(_prefs.getString(_kServerUrl));
|
||||
class ServerUrlNotifier extends Notifier<String?> {
|
||||
@override
|
||||
String? build() {
|
||||
final prefs = ref.watch(sharedPreferencesProvider);
|
||||
return prefs.getString(_kServerUrl);
|
||||
}
|
||||
|
||||
Future<void> setUrl(String url) async {
|
||||
// Strip trailing slash
|
||||
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;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _prefs.remove(_kServerUrl);
|
||||
await ref.read(sharedPreferencesProvider).remove(_kServerUrl);
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import 'api_client_provider.dart';
|
||||
final tasksProvider =
|
||||
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>> {
|
||||
@override
|
||||
Future<List<Task>> build() async {
|
||||
@@ -28,14 +33,14 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
dueDate: dueDate,
|
||||
projectId: projectId,
|
||||
);
|
||||
state = AsyncData([...state.valueOrNull ?? [], task]);
|
||||
state = AsyncData([...state.value ?? [], task]);
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<Task> updateTask(int id, Map<String, dynamic> fields) async {
|
||||
final updated = await ref.read(tasksRepositoryProvider).update(id, fields);
|
||||
state = AsyncData([
|
||||
for (final t in state.valueOrNull ?? [])
|
||||
for (final t in state.value ?? [])
|
||||
if (t.id == id) updated else t,
|
||||
]);
|
||||
return updated;
|
||||
@@ -44,7 +49,7 @@ class TasksNotifier extends AsyncNotifier<List<Task>> {
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(tasksRepositoryProvider).delete(id);
|
||||
state = AsyncData([
|
||||
for (final t in state.valueOrNull ?? [])
|
||||
for (final t in state.value ?? [])
|
||||
if (t.id != id) t,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum UpdateStatus { idle, checking, available, downloading, upToDate, error }
|
||||
|
||||
@@ -102,6 +103,22 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
|
||||
Future<void> downloadAndInstall() async {
|
||||
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);
|
||||
|
||||
try {
|
||||
@@ -119,14 +136,20 @@ class UpdateNotifier extends Notifier<UpdateState> {
|
||||
},
|
||||
);
|
||||
|
||||
await OpenFile.open(
|
||||
final result = await OpenFile.open(
|
||||
path,
|
||||
type: 'application/vnd.android.package-archive',
|
||||
);
|
||||
|
||||
// Transition to idle — the system installer is now open.
|
||||
// Going back to `available` would re-trigger the update dialog loop.
|
||||
state = const UpdateState();
|
||||
if (result.type == ResultType.done) {
|
||||
// Installer launched — reset to idle so the dialog closes naturally.
|
||||
state = const UpdateState();
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
errorMessage: 'Could not open installer: ${result.message}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
status: UpdateStatus.error,
|
||||
|
||||
@@ -72,7 +72,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
_usernameController.text.trim(),
|
||||
_passwordController.text,
|
||||
);
|
||||
if (mounted) context.go(Routes.notes);
|
||||
if (mounted) context.go(Routes.briefing);
|
||||
} on AuthException catch (e) {
|
||||
setState(() => _error = e.message);
|
||||
} on AppException catch (e) {
|
||||
@@ -90,7 +90,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
cookieJar: ref.read(cookieJarProvider),
|
||||
onSuccess: () async {
|
||||
await ref.read(authProvider.notifier).verify();
|
||||
if (mounted) context.go(Routes.notes);
|
||||
if (mounted) context.go(Routes.briefing);
|
||||
},
|
||||
),
|
||||
));
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/models/message.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/weather_card.dart';
|
||||
import 'briefing_history_screen.dart';
|
||||
|
||||
class BriefingScreen extends ConsumerStatefulWidget {
|
||||
@@ -15,13 +18,40 @@ class BriefingScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<BriefingScreen> createState() => _BriefingScreenState();
|
||||
}
|
||||
|
||||
class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
class _BriefingScreenState extends ConsumerState<BriefingScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _controller = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
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
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
_scrollController.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 {
|
||||
setState(() => _refreshing = true);
|
||||
try {
|
||||
@@ -146,47 +192,51 @@ class _BriefingScreenState extends ConsumerState<BriefingScreen> {
|
||||
),
|
||||
),
|
||||
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(
|
||||
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(
|
||||
child: ListView.builder(
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
itemCount: conv.messages.length,
|
||||
itemBuilder: (_, i) =>
|
||||
ChatMessageBubble(message: conv.messages[i]),
|
||||
slivers: [
|
||||
if (conv.messages.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: Center(
|
||||
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 {
|
||||
final VoidCallback? onPressed;
|
||||
final bool isStreaming;
|
||||
|
||||
@@ -70,7 +70,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
|
||||
final convTitle = ref
|
||||
.watch(conversationsProvider)
|
||||
.valueOrNull
|
||||
.value
|
||||
?.where((c) => c.id == widget.conversationId)
|
||||
.firstOrNull
|
||||
?.title;
|
||||
|
||||
@@ -228,8 +228,8 @@ class _LibraryScreenState extends ConsumerState<LibraryScreen> {
|
||||
AsyncValue<List<Note>> notesAsync,
|
||||
AsyncValue<List<Task>> tasksAsync,
|
||||
) {
|
||||
final notes = notesAsync.valueOrNull ?? [];
|
||||
final tasks = tasksAsync.valueOrNull ?? [];
|
||||
final notes = notesAsync.value ?? [];
|
||||
final tasks = tasksAsync.value ?? [];
|
||||
|
||||
// Merge and sort by updatedAt desc
|
||||
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}';
|
||||
}
|
||||
@@ -16,7 +16,7 @@ class NoteDetailScreen extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final noteAsync = ref.watch(noteDetailProvider(noteId));
|
||||
final allNotes = ref.watch(notesProvider).valueOrNull ?? [];
|
||||
final allNotes = ref.watch(notesProvider).value ?? [];
|
||||
|
||||
void navigateByTitle(String title) {
|
||||
final matches = allNotes.where(
|
||||
|
||||
@@ -30,7 +30,7 @@ class _SplashScreenState extends ConsumerState<SplashScreen> {
|
||||
if (!mounted) return;
|
||||
final status = ref.read(authProvider);
|
||||
if (status == AuthStatus.authenticated) {
|
||||
context.go(Routes.notes);
|
||||
context.go(Routes.briefing);
|
||||
} else {
|
||||
context.go(Routes.login);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/constants.dart';
|
||||
import '../../core/exceptions.dart';
|
||||
import '../../data/api/tasks_api.dart';
|
||||
import '../../data/models/task.dart';
|
||||
import '../../providers/api_client_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
@@ -239,6 +238,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskStatus>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _status,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Status',
|
||||
@@ -253,6 +253,7 @@ class _TaskEditScreenState extends ConsumerState<TaskEditScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<TaskPriority>(
|
||||
// ignore: deprecated_member_use
|
||||
value: _priority,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Priority',
|
||||
|
||||
@@ -210,7 +210,8 @@ class ProjectLibraryCard extends StatelessWidget {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () {}, // No project detail screen in this app
|
||||
onTap: () => context
|
||||
.push('/projects/${project.id}/tasks'),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -23,12 +23,13 @@ class ProjectSelector extends ConsumerWidget {
|
||||
|
||||
return projectsAsync.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
data: (projects) {
|
||||
final active =
|
||||
projects.where((p) => p.status == 'active').toList();
|
||||
|
||||
return DropdownButtonFormField<int?>(
|
||||
// ignore: deprecated_member_use
|
||||
value: value,
|
||||
decoration: decoration ??
|
||||
const InputDecoration(
|
||||
|
||||
@@ -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
@@ -1,6 +1,22 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +65,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -81,6 +105,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
cookie_jar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -89,6 +121,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.9"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -109,26 +149,34 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
|
||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.1"
|
||||
version: "5.9.2"
|
||||
dio_cookie_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio_cookie_manager
|
||||
sha256: d39c16abcc711c871b7b29bd51c6b5f3059ef39503916c6a9df7e22c4fc595e0
|
||||
sha256: "0db1a7b997a0455e488ac35744c68eed3f2a4280d3ab531835a65641b0a08744"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
version: "3.4.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -250,20 +298,36 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.3.1"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -276,26 +340,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
version: "17.1.0"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
|
||||
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.3"
|
||||
version: "8.0.2"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
|
||||
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -304,6 +368,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -320,6 +392,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -412,10 +492,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
|
||||
sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f"
|
||||
url: "https://pub.dev"
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -488,14 +576,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.3.1"
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -560,6 +656,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -584,6 +728,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -604,10 +756,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
version: "3.2.1"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -664,11 +816,59 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -717,6 +917,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.30.0"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -725,6 +933,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -757,6 +973,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -765,6 +989,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+7
-5
@@ -2,7 +2,7 @@ name: fabled_app
|
||||
description: "FabledAssistant mobile client for Android."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 26.03.02+1
|
||||
version: 0.0.0+0 # overridden at build time via --build-name / --build-number
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
@@ -12,19 +12,21 @@ dependencies:
|
||||
sdk: flutter
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
flutter_riverpod: ^2.5.1
|
||||
go_router: ^14.2.0
|
||||
flutter_riverpod: ^3.3.1
|
||||
go_router: ^17.1.0
|
||||
dio: ^5.6.0
|
||||
cookie_jar: ^4.0.8
|
||||
dio_cookie_manager: ^3.1.1
|
||||
path_provider: ^2.1.4
|
||||
shared_preferences: ^2.3.2
|
||||
markdown: ^7.2.2
|
||||
package_info_plus: ^8.0.0
|
||||
package_info_plus: ^9.0.0
|
||||
open_file: ^3.3.2
|
||||
permission_handler: ^11.3.1
|
||||
flutter_inappwebview: ^6.1.5
|
||||
flutter_markdown_plus: ^1.0.7
|
||||
google_fonts: ^6.2.1
|
||||
google_fonts: ^8.0.2
|
||||
flutter_timezone: ^5.0.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user