🎉 Initial Commit

This commit is contained in:
2024-11-09 00:09:46 +08:00
commit 2021f7beb9
150 changed files with 8217 additions and 0 deletions

56
lib/main.dart Normal file
View File

@ -0,0 +1,56 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization_loader/easy_localization_loader.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/theme.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/router.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await EasyLocalization.ensureInitialized();
runApp(const SolianApp());
}
class SolianApp extends StatelessWidget {
const SolianApp({super.key});
@override
Widget build(BuildContext context) {
return ResponsiveBreakpoints.builder(
child: EasyLocalization(
path: 'assets/translations',
supportedLocales: [Locale('en', 'US'), Locale('zh', 'CN')],
fallbackLocale: Locale('en', 'US'),
useFallbackTranslations: true,
assetLoader: JsonAssetLoader(),
child: MultiProvider(
providers: [
Provider(create: (_) => SnNetworkProvider()),
ChangeNotifierProvider(create: (_) => UserProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
],
child: Builder(builder: (context) {
final th = context.watch<ThemeProvider>();
return MaterialApp.router(
theme: th.theme.light,
darkTheme: th.theme.dark,
locale: context.locale,
supportedLocales: context.supportedLocales,
localizationsDelegates: context.localizationDelegates,
routerConfig: appRouter,
);
}),
),
),
breakpoints: [
const Breakpoint(start: 0, end: 450, name: MOBILE),
const Breakpoint(start: 451, end: 800, name: TABLET),
const Breakpoint(start: 801, end: 1920, name: DESKTOP),
],
);
}
}

View File

@ -0,0 +1,35 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_smart_retry/dio_smart_retry.dart';
import 'package:flutter/foundation.dart';
import 'package:native_dio_adapter/native_dio_adapter.dart';
const kUseLocalNetwork = false;
class SnNetworkProvider {
late final Dio client;
SnNetworkProvider() {
client = Dio();
client.options.baseUrl = kUseLocalNetwork
? 'http://localhost:8001'
: 'https://api.sn.solsynth.dev';
client.interceptors.add(RetryInterceptor(
dio: client,
retries: 3,
retryDelays: const [
Duration(milliseconds: 300),
Duration(milliseconds: 1000),
Duration(milliseconds: 3000),
],
));
if (!kIsWeb && Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
// Switch to native implementation if possible
client.httpClientAdapter = NativeAdapter();
}
}
}

10
lib/providers/theme.dart Normal file
View File

@ -0,0 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:surface/theme.dart';
class ThemeProvider extends ChangeNotifier {
late ThemeSet theme;
ThemeProvider() {
theme = createAppThemeSet();
}
}

View File

@ -0,0 +1,3 @@
import 'package:flutter/foundation.dart';
class UserProvider extends ChangeNotifier {}

33
lib/router.dart Normal file
View File

@ -0,0 +1,33 @@
import 'package:go_router/go_router.dart';
import 'package:surface/screens/account.dart';
import 'package:surface/screens/explore.dart';
import 'package:surface/screens/home.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
final appRouter = GoRouter(
routes: [
ShellRoute(
builder: (context, state, child) => AppScaffold(
body: child,
showBottomNavigation: true,
),
routes: [
GoRoute(
path: '/',
name: 'home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/posts',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
],
)
],
);

21
lib/screens/account.dart Normal file
View File

@ -0,0 +1,21 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class AccountScreen extends StatefulWidget {
const AccountScreen({super.key});
@override
State<AccountScreen> createState() => _AccountScreenState();
}
class _AccountScreenState extends State<AccountScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text("screenHome").tr(),
),
);
}
}

61
lib/screens/explore.dart Normal file
View File

@ -0,0 +1,61 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/post.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
@override
State<ExploreScreen> createState() => _ExploreScreenState();
}
class _ExploreScreenState extends State<ExploreScreen> {
bool _isBusy = true;
final List<SnPost> _posts = List.empty(growable: true);
int _postCount = 0;
void _fetchPosts() async {
setState(() => _isBusy = true);
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/posts', queryParameters: {
'take': 10,
'offset': 0,
});
_postCount = resp.data['count'];
_posts.addAll(
resp.data['data']?.map((e) => SnPost.fromJson(e)).cast<SnPost>() ?? []);
if (mounted) setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_fetchPosts();
}
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text('screenExplore').tr(),
),
body: InfiniteList(
itemCount: _posts.length,
isLoading: _isBusy,
onFetchData: _fetchPosts,
itemBuilder: (context, idx) {
return Text(_posts[idx].toString());
},
),
);
}
}

21
lib/screens/home.dart Normal file
View File

@ -0,0 +1,21 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:surface/widgets/navigation/app_scaffold.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(
title: Text("screenHome").tr(),
),
);
}
}

25
lib/theme.dart Normal file
View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
class ThemeSet {
ThemeData light;
ThemeData dark;
ThemeSet({required this.light, required this.dark});
}
ThemeSet createAppThemeSet() {
return ThemeSet(
light: createAppTheme(),
dark: createAppTheme(),
);
}
ThemeData createAppTheme() {
return ThemeData(
useMaterial3: false,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo,
brightness: Brightness.light,
),
);
}

92
lib/types/post.dart Normal file
View File

@ -0,0 +1,92 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'post.freezed.dart';
part 'post.g.dart';
@freezed
class SnPost with _$SnPost {
const factory SnPost({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required String type,
required dynamic body,
required String language,
required String? alias,
required String aliasPrefix,
required List<dynamic> tags,
required List<dynamic> categories,
required dynamic reactions,
required dynamic replies,
required dynamic replyId,
required dynamic repostId,
required dynamic replyTo,
required dynamic repostTo,
required dynamic visibleUsersList,
required dynamic invisibleUsersList,
required int visibility,
required DateTime? editedAt,
required DateTime? pinnedAt,
required DateTime? lockedAt,
required bool isDraft,
required DateTime publishedAt,
required dynamic publishedUntil,
required int totalUpvote,
required int totalDownvote,
required int? realmId,
required dynamic realm,
required int publisherId,
required SnPublisher publisher,
required SnMetric metric,
}) = _SnPost;
factory SnPost.fromJson(Map<String, Object?> json) => _$SnPostFromJson(json);
}
@freezed
class SnBody with _$SnBody {
const factory SnBody({
required List<String> attachments,
required String content,
required dynamic location,
required dynamic thumbnail,
required dynamic title,
}) = _SnBody;
factory SnBody.fromJson(Map<String, Object?> json) => _$SnBodyFromJson(json);
}
@freezed
class SnMetric with _$SnMetric {
const factory SnMetric({
required int replyCount,
required int reactionCount,
}) = _SnMetric;
factory SnMetric.fromJson(Map<String, Object?> json) =>
_$SnMetricFromJson(json);
}
@freezed
class SnPublisher with _$SnPublisher {
const factory SnPublisher({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,
required int type,
required String name,
required String nick,
required String description,
required String avatar,
required String banner,
required int totalUpvote,
required int totalDownvote,
required int? realmId,
required int accountId,
}) = _SnPublisher;
factory SnPublisher.fromJson(Map<String, Object?> json) =>
_$SnPublisherFromJson(json);
}

1745
lib/types/post.freezed.dart Normal file

File diff suppressed because it is too large Load Diff

158
lib/types/post.g.dart Normal file
View File

@ -0,0 +1,158 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'post.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
deletedAt: json['deletedAt'] == null
? null
: DateTime.parse(json['deletedAt'] as String),
type: json['type'] as String,
body: json['body'],
language: json['language'] as String,
alias: json['alias'] as String?,
aliasPrefix: json['aliasPrefix'] as String,
tags: json['tags'] as List<dynamic>,
categories: json['categories'] as List<dynamic>,
reactions: json['reactions'],
replies: json['replies'],
replyId: json['replyId'],
repostId: json['repostId'],
replyTo: json['replyTo'],
repostTo: json['repostTo'],
visibleUsersList: json['visibleUsersList'],
invisibleUsersList: json['invisibleUsersList'],
visibility: (json['visibility'] as num).toInt(),
editedAt: json['editedAt'] == null
? null
: DateTime.parse(json['editedAt'] as String),
pinnedAt: json['pinnedAt'] == null
? null
: DateTime.parse(json['pinnedAt'] as String),
lockedAt: json['lockedAt'] == null
? null
: DateTime.parse(json['lockedAt'] as String),
isDraft: json['isDraft'] as bool,
publishedAt: DateTime.parse(json['publishedAt'] as String),
publishedUntil: json['publishedUntil'],
totalUpvote: (json['totalUpvote'] as num).toInt(),
totalDownvote: (json['totalDownvote'] as num).toInt(),
realmId: (json['realmId'] as num?)?.toInt(),
realm: json['realm'],
publisherId: (json['publisherId'] as num).toInt(),
publisher:
SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
metric: SnMetric.fromJson(json['metric'] as Map<String, dynamic>),
);
Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
<String, dynamic>{
'id': instance.id,
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt.toIso8601String(),
'deletedAt': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'body': instance.body,
'language': instance.language,
'alias': instance.alias,
'aliasPrefix': instance.aliasPrefix,
'tags': instance.tags,
'categories': instance.categories,
'reactions': instance.reactions,
'replies': instance.replies,
'replyId': instance.replyId,
'repostId': instance.repostId,
'replyTo': instance.replyTo,
'repostTo': instance.repostTo,
'visibleUsersList': instance.visibleUsersList,
'invisibleUsersList': instance.invisibleUsersList,
'visibility': instance.visibility,
'editedAt': instance.editedAt?.toIso8601String(),
'pinnedAt': instance.pinnedAt?.toIso8601String(),
'lockedAt': instance.lockedAt?.toIso8601String(),
'isDraft': instance.isDraft,
'publishedAt': instance.publishedAt.toIso8601String(),
'publishedUntil': instance.publishedUntil,
'totalUpvote': instance.totalUpvote,
'totalDownvote': instance.totalDownvote,
'realmId': instance.realmId,
'realm': instance.realm,
'publisherId': instance.publisherId,
'publisher': instance.publisher,
'metric': instance.metric,
};
_$SnBodyImpl _$$SnBodyImplFromJson(Map<String, dynamic> json) => _$SnBodyImpl(
attachments: (json['attachments'] as List<dynamic>)
.map((e) => e as String)
.toList(),
content: json['content'] as String,
location: json['location'],
thumbnail: json['thumbnail'],
title: json['title'],
);
Map<String, dynamic> _$$SnBodyImplToJson(_$SnBodyImpl instance) =>
<String, dynamic>{
'attachments': instance.attachments,
'content': instance.content,
'location': instance.location,
'thumbnail': instance.thumbnail,
'title': instance.title,
};
_$SnMetricImpl _$$SnMetricImplFromJson(Map<String, dynamic> json) =>
_$SnMetricImpl(
replyCount: (json['replyCount'] as num).toInt(),
reactionCount: (json['reactionCount'] as num).toInt(),
);
Map<String, dynamic> _$$SnMetricImplToJson(_$SnMetricImpl instance) =>
<String, dynamic>{
'replyCount': instance.replyCount,
'reactionCount': instance.reactionCount,
};
_$SnPublisherImpl _$$SnPublisherImplFromJson(Map<String, dynamic> json) =>
_$SnPublisherImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
deletedAt: json['deletedAt'] == null
? null
: DateTime.parse(json['deletedAt'] as String),
type: (json['type'] as num).toInt(),
name: json['name'] as String,
nick: json['nick'] as String,
description: json['description'] as String,
avatar: json['avatar'] as String,
banner: json['banner'] as String,
totalUpvote: (json['totalUpvote'] as num).toInt(),
totalDownvote: (json['totalDownvote'] as num).toInt(),
realmId: (json['realmId'] as num?)?.toInt(),
accountId: (json['accountId'] as num).toInt(),
);
Map<String, dynamic> _$$SnPublisherImplToJson(_$SnPublisherImpl instance) =>
<String, dynamic>{
'id': instance.id,
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt.toIso8601String(),
'deletedAt': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'name': instance.name,
'nick': instance.nick,
'description': instance.description,
'avatar': instance.avatar,
'banner': instance.banner,
'totalUpvote': instance.totalUpvote,
'totalDownvote': instance.totalDownvote,
'realmId': instance.realmId,
'accountId': instance.accountId,
};

View File

@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
class AppBackground extends StatelessWidget {
final Widget child;
const AppBackground({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(child: child);
}
}

View File

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:surface/widgets/navigation/app_destinations.dart';
class AppBottomNavigationBar extends StatefulWidget {
const AppBottomNavigationBar({super.key});
@override
State<AppBottomNavigationBar> createState() => _AppBottomNavigationBarState();
}
class _AppBottomNavigationBarState extends State<AppBottomNavigationBar> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return BottomNavigationBar(
currentIndex: _currentIndex,
type: BottomNavigationBarType.fixed,
showUnselectedLabels: false,
items: appDestinations.map((ele) {
return BottomNavigationBarItem(
icon: ele.icon,
label: ele.label,
);
}).toList(),
onTap: (idx) {
setState(() => _currentIndex = idx);
GoRouter.of(context).goNamed(appDestinations[idx].screen);
},
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
class AppNavDestination {
final String label;
final String screen;
final Widget icon;
AppNavDestination({
required this.label,
required this.screen,
required this.icon,
});
}
List<AppNavDestination> appDestinations = [
AppNavDestination(
icon: Icon(Symbols.home),
screen: 'home',
label: tr('screenHome'),
),
AppNavDestination(
icon: Icon(Symbols.explore),
screen: 'explore',
label: tr('screenExplore'),
),
AppNavDestination(
icon: Icon(Symbols.account_circle),
screen: 'account',
label: tr('screenAccount'),
),
];

View File

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:responsive_framework/responsive_framework.dart';
import 'package:surface/widgets/navigation/app_background.dart';
import 'package:surface/widgets/navigation/app_bottom_navigation.dart';
class AppScaffold extends StatelessWidget {
final PreferredSizeWidget? appBar;
final Widget? body;
final bool? showBottomNavigation;
const AppScaffold(
{super.key, this.appBar, this.body, this.showBottomNavigation});
@override
Widget build(BuildContext context) {
final isShowBottomNavigation = (showBottomNavigation ?? false)
? ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)
: false;
return AppBackground(
child: Scaffold(
appBar: appBar,
body: body,
bottomNavigationBar:
isShowBottomNavigation ? AppBottomNavigationBar() : null,
),
);
}
}

View File