Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/labfox/lib/app/router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import '../core/auth/auth_controller.dart';
import '../core/auth/auth_state.dart';
import '../features/auth/presentation/sign_in_screen.dart';
import '../features/home/presentation/home_screen.dart';
import '../features/projects/presentation/projects_screen.dart';

/// Route paths.
///
Expand All @@ -14,6 +15,7 @@ import '../features/home/presentation/home_screen.dart';
abstract final class Routes {
static const String home = '/';
static const String signIn = '/sign-in';
static const String projects = '/projects';
}

final routerProvider = Provider<GoRouter>((ref) {
Expand Down Expand Up @@ -53,6 +55,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: Routes.signIn,
builder: (context, state) => const SignInScreen(),
),
GoRoute(
path: Routes.projects,
builder: (context, state) => const ProjectsScreen(),
),
],
);
});
24 changes: 24 additions & 0 deletions apps/labfox/lib/core/auth/gitlab_client_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gitlab_api/gitlab_api.dart';

import 'auth_controller.dart';
import 'auth_providers.dart';

/// A GitLabClient bound to the signed-in account, or null when signed out.
///
/// Rebuilds when the account changes, so switching or signing out replaces the
/// client rather than mutating one in flight — a request cannot end up
/// authenticated as a previous account.
final gitLabClientProvider = FutureProvider<GitLabClient?>((ref) async {
final account = ref.watch(currentAccountProvider);
if (account == null) {
return null;
}
final token = await ref.watch(authRepositoryProvider).tokenFor(account);
if (token == null) {
return null;
}
Comment thread
sjungwon03 marked this conversation as resolved.
Resolved
final client = GitLabClient(baseUrl: account.instanceUrl, token: token);
ref.onDispose(client.close);
return client;
});
30 changes: 30 additions & 0 deletions apps/labfox/lib/features/home/presentation/home_screen.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

import '../../../app/responsive.dart';
import '../../../app/router.dart';
import '../../../core/auth/auth_controller.dart';
import '../../../l10n/app_localizations.dart';

Expand Down Expand Up @@ -70,6 +72,34 @@ class _Body extends StatelessWidget {
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: LabFoxSpacing.lg),
const _MyWork(),
],
),
),
);
}
}

class _MyWork extends StatelessWidget {
const _MyWork();

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Card(
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder_outlined),
title: Text(l10n.homeProjects),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go(Routes.projects),
),
],
),
),
Expand Down
18 changes: 18 additions & 0 deletions apps/labfox/lib/features/projects/data/projects_repository.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:gitlab_api/gitlab_api.dart';
import 'package:gitlab_models/gitlab_models.dart';

/// Loads projects for the signed-in account.
///
/// A thin wrapper over the client for this slice: it exists so the controller
/// never builds a client or knows about pagination headers. Caching (Drift)
/// lands with the offline work later.
class ProjectsRepository {
ProjectsRepository(this._client);

final GitLabClient _client;

Future<List<Project>> list() async {
final page = await _client.projects.list();
return page.items;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gitlab_models/gitlab_models.dart';

import '../../../../core/auth/gitlab_client_provider.dart';
import '../../data/projects_repository.dart';

final projectsRepositoryProvider = FutureProvider<ProjectsRepository?>((
ref,
) async {
final client = await ref.watch(gitLabClientProvider.future);
return client == null ? null : ProjectsRepository(client);
});

/// Loads the signed-in account's projects.
class ProjectsController extends AsyncNotifier<List<Project>> {
@override
Future<List<Project>> build() async {
final repository = await ref.watch(projectsRepositoryProvider.future);
if (repository == null) {
// Signed out: no projects to show. The router keeps this screen behind
// authentication, so this is a defensive empty rather than an expected
// state.
return const [];
}
return repository.list();
}

/// Re-fetches, surfacing loading to the UI for pull-to-refresh.
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final repository = await ref.read(projectsRepositoryProvider.future);
return repository == null ? <Project>[] : repository.list();
});
}
}

final projectsControllerProvider =
AsyncNotifierProvider<ProjectsController, List<Project>>(
ProjectsController.new,
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gitlab_models/gitlab_models.dart';

import '../../../l10n/app_localizations.dart';
import 'controllers/projects_controller.dart';

/// The signed-in account's projects.
class ProjectsScreen extends ConsumerWidget {
const ProjectsScreen({super.key});

@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final projects = ref.watch(projectsControllerProvider);

return Scaffold(
appBar: AppBar(title: Text(l10n.projectsTitle)),
body: projects.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => _ProjectsError(
message: l10n.projectsError,
onRetry: () =>
ref.read(projectsControllerProvider.notifier).refresh(),
),
data: (items) {
if (items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(LabFoxSpacing.lg),
child: Text(l10n.projectsEmpty, textAlign: TextAlign.center),
),
);
}
return RefreshIndicator(
onRefresh: () =>
ref.read(projectsControllerProvider.notifier).refresh(),
child: ListView.separated(
itemCount: items.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) => _tile(items[index]),
),
);
},
),
Comment thread
sjungwon03 marked this conversation as resolved.
Resolved
);
}

Widget _tile(Project project) {
return ProjectTile(
name: project.name,
path: project.pathWithNamespace,
description: project.description,
starCount: project.starCount,
);
}
}

class _ProjectsError extends StatelessWidget {
const _ProjectsError({required this.message, required this.onRetry});

final String message;
final VoidCallback onRetry;

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(LabFoxSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: LabFoxSpacing.md),
FilledButton(onPressed: onRetry, child: Text(l10n.retry)),
],
),
),
);
}
}
77 changes: 64 additions & 13 deletions apps/labfox/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
"@homeSignedInAs": {
"description": "Shown on the home screen with the signed-in user's username",
"placeholders": {
"username": { "type": "String", "example": "jungwon" }
"username": {
"type": "String",
"example": "jungwon"
}
}
},
"homeEmptyWork": "Your issues, merge requests and pipelines will appear here.",
Expand All @@ -28,27 +31,75 @@
"description": "Heading on the sign-in screen"
},
"signInInstanceLabel": "GitLab instance URL",
"@signInInstanceLabel": { "description": "Label for the instance URL field" },
"@signInInstanceLabel": {
"description": "Label for the instance URL field"
},
"signInInstanceRequired": "Enter your GitLab instance URL.",
"@signInInstanceRequired": { "description": "Validation when the instance URL is empty" },
"@signInInstanceRequired": {
"description": "Validation when the instance URL is empty"
},
"signInInstanceInvalid": "Enter a valid https URL, for example https://gitlab.com.",
"@signInInstanceInvalid": { "description": "Validation when the instance URL is not a valid https URL" },
"@signInInstanceInvalid": {
"description": "Validation when the instance URL is not a valid https URL"
},
"signInTokenLabel": "Personal Access Token",
"@signInTokenLabel": { "description": "Label for the token field" },
"@signInTokenLabel": {
"description": "Label for the token field"
},
"signInTokenHelp": "Needs the api and read_user scopes.",
"@signInTokenHelp": { "description": "Helper text under the token field" },
"@signInTokenHelp": {
"description": "Helper text under the token field"
},
"signInTokenToggle": "Show or hide the token",
"@signInTokenToggle": { "description": "Tooltip for the token visibility toggle" },
"@signInTokenToggle": {
"description": "Tooltip for the token visibility toggle"
},
"signInTokenRequired": "Enter a Personal Access Token.",
"@signInTokenRequired": { "description": "Validation when the token is empty" },
"@signInTokenRequired": {
"description": "Validation when the token is empty"
},
"signInSubmit": "Sign in",
"@signInSubmit": { "description": "Label for the sign-in button" },
"@signInSubmit": {
"description": "Label for the sign-in button"
},
"signInErrorToken": "The token was rejected. Check that it is correct and has not expired.",
"@signInErrorToken": { "description": "Shown when the instance returns 401" },
"@signInErrorToken": {
"description": "Shown when the instance returns 401"
},
"signInErrorScope": "The token is missing a required scope. It needs api and read_user.",
"@signInErrorScope": { "description": "Shown when the instance returns 403" },
"@signInErrorScope": {
"description": "Shown when the instance returns 403"
},
"signInErrorUnreachable": "Could not reach that instance. Check the URL, your network, and whether the certificate is trusted.",
"@signInErrorUnreachable": { "description": "Shown when the instance cannot be reached" },
"@signInErrorUnreachable": {
"description": "Shown when the instance cannot be reached"
},
"signInErrorGeneric": "Sign-in failed. Please try again.",
"@signInErrorGeneric": { "description": "Fallback sign-in error" }
"@signInErrorGeneric": {
"description": "Fallback sign-in error"
},
"homeMyWork": "My work",
"@homeMyWork": {
"description": "Section heading on the home screen"
},
"homeProjects": "Projects",
"@homeProjects": {
"description": "Home entry that opens the projects list"
},
"projectsTitle": "Projects",
"@projectsTitle": {
"description": "Title of the projects list screen"
},
"projectsEmpty": "You are not a member of any projects yet.",
"@projectsEmpty": {
"description": "Shown when the projects list is empty"
},
"projectsError": "Could not load your projects.",
"@projectsError": {
"description": "Shown when the projects list fails to load"
},
"retry": "Retry",
"@retry": {
"description": "Label for a retry button"
}
}
8 changes: 7 additions & 1 deletion apps/labfox/lib/l10n/app_hi.arb
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@
"signInErrorToken": "टोकन अस्वीकृत हो गया। जाँचें कि यह सही है और समाप्त नहीं हुआ है।",
"signInErrorScope": "टोकन में आवश्यक स्कोप नहीं है। इसे api और read_user चाहिए।",
"signInErrorUnreachable": "उस इंस्टेंस तक नहीं पहुँच सके। URL, अपना नेटवर्क और प्रमाणपत्र भरोसे की जाँच करें।",
"signInErrorGeneric": "साइन इन विफल रहा। कृपया पुनः प्रयास करें।"
"signInErrorGeneric": "साइन इन विफल रहा। कृपया पुनः प्रयास करें।",
"homeMyWork": "मेरा काम",
"homeProjects": "प्रोजेक्ट",
"projectsTitle": "प्रोजेक्ट",
"projectsEmpty": "आप अभी तक किसी प्रोजेक्ट के सदस्य नहीं हैं।",
"projectsError": "आपके प्रोजेक्ट लोड नहीं हो सके।",
"retry": "पुनः प्रयास करें"
}
8 changes: 7 additions & 1 deletion apps/labfox/lib/l10n/app_ja.arb
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@
"signInErrorToken": "トークンが拒否されました。正しいか、有効期限が切れていないか確認してください。",
"signInErrorScope": "トークンに必要なスコープがありません。api と read_user が必要です。",
"signInErrorUnreachable": "そのインスタンスに接続できませんでした。URL、ネットワーク、証明書の信頼を確認してください。",
"signInErrorGeneric": "サインインに失敗しました。もう一度お試しください。"
"signInErrorGeneric": "サインインに失敗しました。もう一度お試しください。",
"homeMyWork": "マイワーク",
"homeProjects": "プロジェクト",
"projectsTitle": "プロジェクト",
"projectsEmpty": "まだどのプロジェクトにも参加していません。",
"projectsError": "プロジェクトを読み込めませんでした。",
"retry": "再試行"
}
8 changes: 7 additions & 1 deletion apps/labfox/lib/l10n/app_ko.arb
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,11 @@
"signInErrorToken": "토큰이 거부되었습니다. 올바른지, 만료되지 않았는지 확인하세요.",
"signInErrorScope": "토큰에 필요한 스코프가 없습니다. api와 read_user가 필요합니다.",
"signInErrorUnreachable": "해당 인스턴스에 연결할 수 없습니다. URL, 네트워크, 인증서 신뢰 여부를 확인하세요.",
"signInErrorGeneric": "로그인에 실패했습니다. 다시 시도하세요."
"signInErrorGeneric": "로그인에 실패했습니다. 다시 시도하세요.",
"homeMyWork": "내 작업",
"homeProjects": "프로젝트",
"projectsTitle": "프로젝트",
"projectsEmpty": "아직 참여 중인 프로젝트가 없습니다.",
"projectsError": "프로젝트를 불러올 수 없습니다.",
"retry": "다시 시도"
}
Loading
Loading